Skip to main content

mj_controller/controller/
worker_binary.rs

1//! Worker binary acquisition, profile staging, and worker installation.
2
3use std::collections::{HashMap, HashSet};
4use std::fs::File;
5use std::io::{ErrorKind, Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8use std::time::Instant;
9
10use anyhow::{Context, Result, bail, ensure};
11use rayon::prelude::*;
12use sha2::{Digest, Sha256};
13
14use crate::session_manager::{
15    ProjectMemorySyncTarget, RemoteWorkerBinaryRefresh, WorkerBinaryRefresh,
16    WorkerBinaryRefreshPlan, WorkerLaunchRefreshPlan, WorkerRecoveryPlan, WorkerWorkspace,
17};
18use crate::targets::{
19    self, CommandExecutor, CommandPlan, CommandSpec, ProcessExecutor, ProvisionStage, SshTarget,
20};
21use mj_core::config::{
22    HarnessKind, HarnessProfile, ProjectBundle, ProjectRepository, atomic_write, data_dir,
23};
24use mj_core::harness_runtime::{CLAUDE_ACP_VERSION, CODEX_ACP_PACKAGE, CODEX_ACP_VERSION};
25use mj_core::project_memory::{ProjectMemoryIdentity, RepositoryMemoryIdentity};
26use mj_core::worker_launch::{
27    HarnessRuntimePolicy, ProjectMemoryLaunchConfig, ProjectMemoryMcpDelivery, WorkerLaunchConfig,
28    WorkerOwnership,
29};
30
31use super::backend::backend_locator;
32use super::readiness::WORKER_EXIT_RECORD_MARKER;
33use super::{Controller, execute_checked, scp_command_spec, ssh_command_spec, target_profile_home};
34
35impl Controller {
36    /// Where this session's worker lives. This is decided from the session
37    /// record and configuration alone, so a caller can name the worker root
38    /// before anything is installed into it.
39    pub(super) fn worker_placement(
40        &self,
41        session_id: &str,
42    ) -> Result<(targets::TargetLocator, String)> {
43        let session = self
44            .state
45            .sessions
46            .get(session_id)
47            .with_context(|| format!("unknown session {session_id}"))?;
48        let locator = session
49            .target
50            .as_ref()
51            .context("session target is missing")?;
52        let backend = backend_locator(locator, session, &self.config)?;
53        let worker_root = targets::worker_root(&backend, session_id)?;
54        Ok((backend, worker_root))
55    }
56
57    pub(super) fn prepare_worker_files(
58        &self,
59        session_id: &str,
60        backend: &targets::TargetLocator,
61        worker_root: &str,
62        executor: &impl CommandExecutor,
63    ) -> Result<()> {
64        let session = self
65            .state
66            .sessions
67            .get(session_id)
68            .with_context(|| format!("unknown session {session_id}"))?;
69        session.validate_configuration(&self.config)?;
70        let profile = self
71            .config
72            .profiles
73            .get(&session.last_profile)
74            .context("session profile is missing")?;
75        let bundle = session
76            .project_directory
77            .is_none()
78            .then(|| self.config.bundles.get(&session.bundle_id))
79            .flatten();
80        let target = self
81            .config
82            .targets
83            .get(&session.target_template_id)
84            .context("session target template is missing")?;
85        let subagent = crate::database::load_subagent(session_id)?;
86        let workspace_session_id = subagent.as_ref().map_or_else(
87            || session_id.to_owned(),
88            |child| child.parent_session_id.clone(),
89        );
90        let (mut launch, project_memory, target_profile_home) = worker_launch_config(
91            session,
92            profile,
93            bundle,
94            backend,
95            session_id,
96            &workspace_session_id,
97            target,
98        )?;
99        launch.subagent_tools =
100            subagent_tools_enabled(session, self.config.subagents.enabled, subagent.is_some());
101        if let Some(subagent) = &subagent {
102            let parent = self
103                .state
104                .sessions
105                .get(&subagent.parent_session_id)
106                .context("sub-agent parent session is missing")?;
107            let parent_profile = self
108                .config
109                .profiles
110                .get(&parent.last_profile)
111                .context("sub-agent parent profile is missing")?;
112            let parent_target = self
113                .config
114                .targets
115                .get(&parent.target_template_id)
116                .context("sub-agent parent target template is missing")?;
117            let parent_locator = parent
118                .target
119                .as_ref()
120                .context("sub-agent parent has no live target")?;
121            let parent_backend = backend_locator(parent_locator, parent, &self.config)?;
122            let parent_bundle = parent
123                .project_directory
124                .is_none()
125                .then(|| self.config.bundles.get(&parent.bundle_id))
126                .flatten();
127            let (parent_launch, _, _) = worker_launch_config(
128                parent,
129                parent_profile,
130                parent_bundle,
131                &parent_backend,
132                &parent.id,
133                &parent.id,
134                parent_target,
135            )?;
136            launch.cwd = if subagent.working_directory.as_os_str().is_empty() {
137                parent_launch.cwd
138            } else {
139                parent_launch.cwd.join(&subagent.working_directory)
140            };
141            launch.additional_directories = parent_launch.additional_directories;
142        }
143
144        if session.native_session_id.is_some()
145            && profile.kind == mj_core::config::HarnessKind::Codex
146        {
147            launch.goal_resume_request = Some(mj_core::state::new_session_id()?);
148        }
149        let staging = tempfile::tempdir().context("create worker staging directory")?;
150        let launch_path = staging.path().join("launch.json");
151        launch.write(&launch_path)?;
152        let ownership_path = staging.path().join("ownership.json");
153        WorkerOwnership {
154            version: WorkerOwnership::VERSION,
155            workspace_id: session.workspace_id.clone(),
156            session_id: session_id.to_string(),
157            profile_id: session.last_profile.clone(),
158            bundle_id: session.bundle_id.clone(),
159            target_template_id: session.target_template_id.clone(),
160        }
161        .write(&ownership_path)?;
162        let profile_stage = staging.path().join("profile");
163        if !matches!(backend, targets::TargetLocator::LocalBare { .. })
164            || matches!(
165                profile.kind,
166                mj_core::config::HarnessKind::Claude | mj_core::config::HarnessKind::Muse
167            )
168            || super::requires_private_profile_home(profile)
169        {
170            let started = Instant::now();
171            let result = stage_profile(profile, &profile_stage);
172            tracing::debug!(
173                session_id,
174                elapsed_ms = started.elapsed().as_millis(),
175                "profile staging completed"
176            );
177            result?;
178            stage_codex_catalog(
179                &session.last_profile,
180                profile,
181                &profile_stage,
182                &fetch_catalog_over_https,
183                &SharedCatalogCache,
184            )?;
185            append_hel_target_environment(profile.kind, &profile_stage, backend)?;
186            apply_staged_execution_setting(profile.kind, launch.execution_policy, &profile_stage)?;
187            if launch.subagent_tools && profile.kind == mj_core::config::HarnessKind::Claude {
188                configure_claude_subagent_mcp(&profile_stage, worker_root)?;
189            }
190            stage_memory_replica(
191                &project_memory,
192                Path::new(&target_profile_home),
193                &profile_stage,
194            )?;
195            if project_memory.mcp_delivery == ProjectMemoryMcpDelivery::HarnessProfile {
196                configure_kimi_project_memory_mcp(&profile_stage, worker_root, &project_memory)?;
197            }
198        } else {
199            seed_local_memory_replica(&project_memory)?;
200        }
201        let worker_binary = worker_binary_for(backend, executor)?;
202
203        install_worker_files(
204            executor,
205            backend,
206            session_id,
207            worker_root,
208            &target_profile_home,
209            &worker_binary,
210            &launch_path,
211            &ownership_path,
212            &profile_stage,
213        )?;
214        prepare_installed_managed_harness(executor, backend, worker_root, &launch)
215    }
216
217    /// Probe the installed binary and collect the dead worker's exit record
218    /// and log tail after a session becomes unreachable. Best-effort; returns
219    /// `None` when the target no longer exists or has no diagnostics.
220    pub fn diagnose_worker(&self, session_id: &str) -> Option<String> {
221        self.diagnose_worker_controlled(session_id, &ProcessExecutor)
222    }
223
224    pub fn diagnose_worker_controlled(
225        &self,
226        session_id: &str,
227        executor: &impl CommandExecutor,
228    ) -> Option<String> {
229        let session = self.state.sessions.get(session_id)?;
230        let locator = session.target.as_ref()?;
231        let backend = match backend_locator(locator, session, &self.config) {
232            Ok(backend) => backend,
233            Err(error) => {
234                tracing::debug!(
235                    session_id,
236                    error = format!("{error:#}"),
237                    "could not construct a worker diagnostic probe"
238                );
239                return None;
240            }
241        };
242        let worker_root = match targets::worker_root(&backend, session_id) {
243            Ok(root) => root,
244            Err(error) => {
245                tracing::debug!(
246                    session_id,
247                    error = format!("{error:#}"),
248                    "could not derive the worker diagnostic root"
249                );
250                return None;
251            }
252        };
253        let binary_failure = worker_binary_probe_failure(executor, &backend, &worker_root);
254        let last_words = worker_last_words(executor, &backend, &worker_root);
255        match (binary_failure, last_words) {
256            (Some(binary_failure), Some(last_words)) => {
257                Some(format!("{binary_failure}; {last_words}"))
258            }
259            (Some(binary_failure), None) => Some(binary_failure),
260            (None, last_words) => last_words,
261        }
262    }
263
264    /// A non-destructive liveness probe plus commands that replace a confirmed
265    /// dead session worker without touching its durable relay files. The
266    /// session manager runs both off its async actor.
267    pub fn worker_recovery_plan(&self, session_id: &str) -> Result<WorkerRecoveryPlan> {
268        let (backend, worker_root) = self.worker_placement(session_id)?;
269        let launch = self.current_worker_launch_config(session_id, &backend)?;
270        let workspace = worker_workspace_for_recovery(&backend, &launch.cwd);
271        Ok(WorkerRecoveryPlan {
272            source_target: self.state.sessions[session_id]
273                .target
274                .clone()
275                .context("session target is missing")?,
276            target: targets::target_recovery_plan(&backend, session_id)?,
277            workspace,
278            liveness_probe: worker_liveness_command(&backend, &worker_root),
279            binary_refresh: worker_binary_refresh_plan(&backend, session_id)?,
280            launch_refresh: Some(worker_launch_refresh_plan(&backend, session_id, &launch)?),
281            restart: CommandPlan {
282                description: format!("restart Mjolnir worker for session {session_id}"),
283                commands: vec![
284                    stop_worker_command(&backend, &worker_root),
285                    start_worker_command(&backend, &worker_root),
286                ],
287            },
288        })
289    }
290
291    pub(super) fn current_worker_launch_config(
292        &self,
293        session_id: &str,
294        backend: &targets::TargetLocator,
295    ) -> Result<WorkerLaunchConfig> {
296        let session = self
297            .state
298            .sessions
299            .get(session_id)
300            .with_context(|| format!("unknown session {session_id}"))?;
301        session.validate_configuration(&self.config)?;
302        let profile = self
303            .config
304            .profiles
305            .get(&session.last_profile)
306            .context("session profile is missing")?;
307        let bundle = session
308            .project_directory
309            .is_none()
310            .then(|| self.config.bundles.get(&session.bundle_id))
311            .flatten();
312        let target = self
313            .config
314            .targets
315            .get(&session.target_template_id)
316            .context("session target template is missing")?;
317        let (mut launch, _, _) = worker_launch_config(
318            session, profile, bundle, backend, session_id, session_id, target,
319        )?;
320        if crate::database::load_move_operation(session_id)?.is_some_and(|operation| {
321            operation.source_checkpoint_only
322                && operation.destination_target.is_none()
323                && matches!(
324                    operation.phase,
325                    mj_core::state::MovePhase::Preparing
326                        | mj_core::state::MovePhase::ClosingSource
327                        | mj_core::state::MovePhase::Failed
328                        | mj_core::state::MovePhase::Cancelled
329                )
330                && session.last_profile == operation.source_profile_id
331                && session.target == operation.source_target
332                && matches!(
333                    session.state,
334                    mj_core::state::SessionState::Running
335                        | mj_core::state::SessionState::Disconnected
336                        | mj_core::state::SessionState::Closing
337                )
338        }) {
339            launch.run_mode = mj_core::worker_launch::WorkerRunMode::CheckpointOnly;
340        }
341        Ok(launch)
342    }
343
344    pub fn project_memory_sync_target(&self, session_id: &str) -> Result<ProjectMemorySyncTarget> {
345        let session = self
346            .state
347            .sessions
348            .get(session_id)
349            .with_context(|| format!("unknown session {session_id}"))?;
350        session.validate_configuration(&self.config)?;
351        let locator = session
352            .target
353            .as_ref()
354            .context("session target is missing")?;
355        let backend = backend_locator(locator, session, &self.config)?;
356        let profile = self
357            .config
358            .profiles
359            .get(&session.last_profile)
360            .context("session profile is missing")?;
361        let bundle = session
362            .project_directory
363            .is_none()
364            .then(|| self.config.bundles.get(&session.bundle_id))
365            .flatten();
366        let workspace = if let Some(project_directory) = &session.project_directory {
367            (project_directory.to_string_lossy().into_owned(), Vec::new())
368        } else {
369            workspace_paths(
370                &backend,
371                bundle.context("session bundle is missing")?,
372                session_id,
373            )?
374        };
375        let target_home = target_profile_home(&backend, session_id, profile);
376        let launch = project_memory_launch(session, bundle, &workspace, &target_home)?;
377        Ok(ProjectMemorySyncTarget {
378            canonical_root: canonical_memory_root(&launch.project_key),
379        })
380    }
381}
382
383/// Whether this session gets Mjolnir's delegation tools in place of its
384/// harness's own. The session's stored choice governs and `None` follows the
385/// global `[subagents] enabled` setting, so a session created before the
386/// per-session choice existed behaves as it always did. A child never gets
387/// them, and only Claude and Codex can receive them at all.
388fn subagent_tools_enabled(
389    session: &mj_core::state::SessionRecord,
390    global_enabled: bool,
391    is_child: bool,
392) -> bool {
393    session.mjolnir_subagents.unwrap_or(global_enabled)
394        && !is_child
395        && matches!(
396            session.harness_kind,
397            mj_core::config::HarnessKind::Claude | mj_core::config::HarnessKind::Codex
398        )
399}
400
401fn worker_workspace_for_recovery(
402    backend: &targets::TargetLocator,
403    directory: &Path,
404) -> Option<WorkerWorkspace> {
405    let target = match backend {
406        targets::TargetLocator::LocalBare { .. } => mj_core::state::ManagedWorktreeTarget::Local,
407        targets::TargetLocator::SshBare { ssh, .. } => mj_core::state::ManagedWorktreeTarget::Ssh {
408            destination: ssh.destination.clone(),
409            ssh_args: ssh.ssh_args.clone(),
410        },
411        targets::TargetLocator::LocalPodman { .. }
412        | targets::TargetLocator::LocalDocker { .. }
413        | targets::TargetLocator::AppleContainer { .. }
414        | targets::TargetLocator::AwsEc2 { .. }
415        | targets::TargetLocator::SshPodman { .. }
416        | targets::TargetLocator::SshDocker { .. } => return None,
417    };
418    Some(WorkerWorkspace {
419        target,
420        directory: directory.to_path_buf(),
421    })
422}
423
424fn worker_launch_config(
425    session: &mj_core::state::SessionRecord,
426    profile: &mj_core::config::HarnessProfile,
427    bundle: Option<&ProjectBundle>,
428    backend: &targets::TargetLocator,
429    session_id: &str,
430    workspace_session_id: &str,
431    target: &mj_core::config::TargetTemplate,
432) -> Result<(WorkerLaunchConfig, ProjectMemoryLaunchConfig, String)> {
433    let execution_policy = profile
434        .kind
435        .effective_execution_policy(target.execution_policy());
436    let target_profile_home = target_profile_home(backend, session_id, profile);
437    let workspace = if let Some(project_directory) = &session.project_directory {
438        (project_directory.to_string_lossy().into_owned(), Vec::new())
439    } else {
440        workspace_paths(
441            backend,
442            bundle.context("session bundle is missing")?,
443            workspace_session_id,
444        )?
445    };
446    let mut additional_directories = workspace.1.iter().map(PathBuf::from).collect::<Vec<_>>();
447    additional_directories.extend(
448        session
449            .additional_mounts
450            .iter()
451            .map(|resource| resource.destination.clone()),
452    );
453    if profile.kind == mj_core::config::HarnessKind::Muse && !additional_directories.is_empty() {
454        bail!(
455            "{} ACP does not support multiple workspace roots; use a single-repository bundle",
456            profile.kind.display_name()
457        );
458    }
459    let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
460    use mj_core::config::TargetTemplate;
461    let target_environment = match target {
462        TargetTemplate::LocalPodman { container }
463        | TargetTemplate::LocalDocker { container }
464        | TargetTemplate::AppleContainer { container }
465        | TargetTemplate::SshPodman { container, .. }
466        | TargetTemplate::SshDocker { container, .. } => container.environment.clone(),
467        _ => Default::default(),
468    };
469    let mut environment = target_environment.clone();
470    environment.extend(profile.environment.clone());
471    profile
472        .kind
473        .configure_home_environment(Path::new(&target_profile_home), &mut environment);
474    profile
475        .kind
476        .configure_execution_environment(execution_policy, &mut environment)?;
477    environment.remove(mj_core::worker_launch::DISCOVER_LOGIN_PATH_ENV);
478    let mut project_memory =
479        project_memory_launch(session, bundle, &workspace, &target_profile_home)?;
480    project_memory.mcp_delivery = project_memory_mcp_delivery(profile.kind, backend);
481    if profile.kind == mj_core::config::HarnessKind::Claude {
482        environment.insert(
483            "CLAUDE_CODE_PROJECT_DIR_NAME".into(),
484            project_memory_replica_slug(&project_memory.project_key, session_id),
485        );
486    }
487    apply_claude_setup_token(
488        &mut environment,
489        profile.kind,
490        &mj_core::credentials::claude_oauth_token_path(&session.last_profile),
491    );
492    Ok((
493        WorkerLaunchConfig {
494            goal_resume_request: None,
495            target_environment,
496            run_mode: Default::default(),
497            session_id: session_id.to_string(),
498            subagent_tools: false,
499            harness: profile.kind,
500            // The staged home mirrors the profile home, so the controller's
501            // marker file name is the one the worker must check.
502            authentication_marker: profile
503                .authentication_marker()
504                .file_name()
505                .map(|name| name.to_string_lossy().into_owned()),
506            bridge_command: PathBuf::from(bridge_command),
507            bridge_args,
508            harness_runtime: harness_runtime_policy(backend),
509            environment,
510            cwd: PathBuf::from(&workspace.0),
511            additional_directories,
512            native_session_id: session.native_session_id.clone(),
513            project_memory: profile
514                .kind
515                .supports_injected_mcp()
516                .then(|| project_memory.clone()),
517            execution_policy,
518        },
519        project_memory,
520        target_profile_home,
521    ))
522}
523
524fn harness_runtime_policy(backend: &targets::TargetLocator) -> HarnessRuntimePolicy {
525    match backend {
526        targets::TargetLocator::LocalBare { .. }
527        | targets::TargetLocator::AwsEc2 { .. }
528        | targets::TargetLocator::SshBare { .. } => HarnessRuntimePolicy::Managed,
529        _ => HarnessRuntimePolicy::Ambient,
530    }
531}
532
533/// Hand a Claude worker the profile's long-lived setup token, when it has one.
534///
535/// Claude Code reads `CLAUDE_CODE_OAUTH_TOKEN` ahead of the `/login`
536/// credentials file, and a setup token does not rotate, so a container copy
537/// cannot lose the single-use refresh race with the host. A profile that sets
538/// the variable itself stays authoritative.
539pub(super) fn apply_claude_setup_token(
540    environment: &mut std::collections::BTreeMap<String, String>,
541    kind: mj_core::config::HarnessKind,
542    token_path: &Path,
543) {
544    use mj_core::credentials::CLAUDE_OAUTH_TOKEN_ENV;
545
546    if kind != mj_core::config::HarnessKind::Claude
547        || environment.contains_key(CLAUDE_OAUTH_TOKEN_ENV)
548    {
549        return;
550    }
551    match mj_core::credentials::read_claude_oauth_token(token_path) {
552        Ok(Some(token)) => {
553            environment.insert(CLAUDE_OAUTH_TOKEN_ENV.to_owned(), token);
554        }
555        Ok(None) => {}
556        // A stored token Hel cannot read is worth reporting, but the session
557        // still starts on the synced credentials file.
558        Err(error) => tracing::warn!(
559            path = %token_path.display(),
560            %error,
561            "ignoring an unreadable Claude setup token"
562        ),
563    }
564}
565
566fn project_memory_launch(
567    session: &mj_core::state::SessionRecord,
568    bundle: Option<&ProjectBundle>,
569    workspace: &(String, Vec<String>),
570    target_profile_home: &str,
571) -> Result<ProjectMemoryLaunchConfig> {
572    let identity = if let Some(worktree) = &session.managed_worktree {
573        ProjectMemoryIdentity::Repository {
574            repository: RepositoryMemoryIdentity::Local {
575                canonical_root: std::fs::canonicalize(&worktree.source_repository)
576                    .unwrap_or_else(|_| worktree.source_repository.clone()),
577            },
578        }
579    } else if let Some(bundle) = bundle {
580        let primary =
581            configured_memory_identity(bundle.primary().context("bundle primary is missing")?)?;
582        let members = bundle
583            .repositories
584            .iter()
585            .map(configured_memory_identity)
586            .collect::<Result<Vec<_>>>()?;
587        ProjectMemoryIdentity::bundle(primary, members)
588    } else {
589        let project = session
590            .project_directory
591            .as_ref()
592            .context("raw session project directory is missing")?;
593        let repository = match session.target.as_ref() {
594            Some(mj_core::state::TargetLocator::LocalBare { .. }) => {
595                RepositoryMemoryIdentity::Local {
596                    canonical_root: std::fs::canonicalize(project)
597                        .unwrap_or_else(|_| project.clone()),
598                }
599            }
600            _ => RepositoryMemoryIdentity::Remote {
601                target: session.target_template_id.clone(),
602                canonical_root: project.clone(),
603            },
604        };
605        ProjectMemoryIdentity::Repository { repository }
606    };
607    let project_key = identity.key()?;
608    let replica_slug = project_memory_replica_slug(&project_key, &session.id);
609    let project_root = PathBuf::from(target_profile_home)
610        .join("projects")
611        .join(replica_slug);
612    let root = project_root.join("memory");
613    let baseline_root = project_root.join(".hel-memory-baseline");
614    let mut repository_roots = std::collections::BTreeMap::new();
615    if let Some(bundle) = bundle {
616        let target_roots =
617            std::iter::once(workspace.0.as_str()).chain(workspace.1.iter().map(String::as_str));
618        let repositories = std::iter::once(bundle.primary().context("bundle primary is missing")?)
619            .chain(
620                bundle
621                    .repositories
622                    .iter()
623                    .filter(|repository| repository.id != bundle.primary_repo),
624            );
625        repository_roots.extend(
626            repositories
627                .zip(target_roots)
628                .map(|(repository, root)| (repository.id.clone(), PathBuf::from(root))),
629        );
630    }
631    Ok(ProjectMemoryLaunchConfig {
632        project_key,
633        root,
634        baseline_root,
635        repository_roots,
636        mcp_delivery: ProjectMemoryMcpDelivery::Acp,
637    })
638}
639
640fn project_memory_replica_slug(project_key: &str, session_id: &str) -> String {
641    format!("hel-{}-{session_id}", &project_key[..16])
642}
643
644fn project_memory_mcp_delivery(
645    harness: mj_core::config::HarnessKind,
646    target: &targets::TargetLocator,
647) -> ProjectMemoryMcpDelivery {
648    if harness == mj_core::config::HarnessKind::Kimi
649        && !matches!(target, targets::TargetLocator::LocalBare { .. })
650    {
651        ProjectMemoryMcpDelivery::HarnessProfile
652    } else {
653        ProjectMemoryMcpDelivery::Acp
654    }
655}
656
657fn configured_memory_identity(repository: &ProjectRepository) -> Result<RepositoryMemoryIdentity> {
658    if let Some(source) = repository.github.as_deref() {
659        let github = crate::setup::github_repository_from_origin(source)
660            .with_context(|| format!("parse repository source {source:?} for project memory"))?;
661        return Ok(RepositoryMemoryIdentity::Github {
662            owner: github.owner.to_ascii_lowercase(),
663            repository: github.repository.to_ascii_lowercase(),
664        });
665    }
666    let root = repository
667        .local
668        .as_ref()
669        .context("project repository has no source for memory identity")?;
670    Ok(RepositoryMemoryIdentity::Local {
671        canonical_root: mj_core::local_git::main_worktree_root(root)
672            .or_else(|_| std::fs::canonicalize(root).map_err(anyhow::Error::from))
673            .unwrap_or_else(|_| root.clone()),
674    })
675}
676
677fn canonical_memory_root(project_key: &str) -> PathBuf {
678    data_dir().join("projects").join(project_key).join("memory")
679}
680
681fn stage_memory_replica(
682    memory: &ProjectMemoryLaunchConfig,
683    target_profile_home: &Path,
684    profile_stage: &Path,
685) -> Result<()> {
686    let canonical = canonical_memory_root(&memory.project_key);
687    std::fs::create_dir_all(&canonical)?;
688    let replica = memory.root.strip_prefix(target_profile_home)?;
689    let baseline = memory.baseline_root.strip_prefix(target_profile_home)?;
690    copy_profile_entry(&canonical, &profile_stage.join(replica))?;
691    copy_profile_entry(&canonical, &profile_stage.join(baseline))
692}
693
694fn seed_local_memory_replica(memory: &ProjectMemoryLaunchConfig) -> Result<()> {
695    let canonical = canonical_memory_root(&memory.project_key);
696    std::fs::create_dir_all(&canonical)?;
697    let canonical_has_files = directory_has_files(&canonical)?;
698    let replica_has_files = directory_has_files(&memory.root)?;
699    match (canonical_has_files, replica_has_files) {
700        (false, true) => copy_profile_entry(&memory.root, &canonical),
701        (true, false) => copy_profile_entry(&canonical, &memory.root),
702        _ => Ok(()),
703    }?;
704    copy_profile_entry(&canonical, &memory.baseline_root)
705}
706
707/// Kimi's runtime-aware engine cannot infer a runtime identity from an ACP
708/// stdio server. Add Hel's server to the session-private profile instead,
709/// where Kimi's native schema can bind it to the target's local runtime.
710fn configure_kimi_project_memory_mcp(
711    profile_stage: &Path,
712    worker_root: &str,
713    memory: &ProjectMemoryLaunchConfig,
714) -> Result<()> {
715    let path = profile_stage.join("mcp.json");
716    edit_staged_json_object(&path, "staged Kimi MCP configuration", |root| {
717        let servers = root
718            .entry("mcpServers")
719            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
720            .as_object_mut()
721            .with_context(|| {
722                format!(
723                    "mcpServers in staged Kimi MCP configuration {} must be a JSON object",
724                    path.display()
725                )
726            })?;
727
728        let worker = Path::new(worker_root).join("hel");
729        let server = if worker.is_absolute() && memory.root.is_absolute() {
730            serde_json::json!({
731                "transport": "stdio",
732                "command": worker,
733                "args": ["worker", "memory-mcp", "--root", memory.root],
734                "runtime_id": "local"
735            })
736        } else {
737            let worker = worker.to_string_lossy();
738            let memory_root = memory.root.to_string_lossy();
739            serde_json::json!({
740                "transport": "stdio",
741                "command": "sh",
742                "args": [
743                    "-c",
744                    "exec \"$HOME/$1\" worker memory-mcp --root \"$HOME/$2\"",
745                    "mj-memory",
746                    worker,
747                    memory_root
748                ],
749                "runtime_id": "local"
750            })
751        };
752        servers.insert("mj-memory".into(), server);
753        Ok(())
754    })
755}
756
757/// Claude reads MCP servers from its private profile rather than ACP. Parent
758/// sessions always use an isolated staged profile, including on local bare
759/// targets, so this never modifies the user's source profile.
760fn configure_claude_subagent_mcp(profile_stage: &Path, worker_root: &str) -> Result<()> {
761    let path = profile_stage.join(".claude.json");
762    edit_staged_json_object(&path, "staged Claude configuration", |root| {
763        let servers = root
764            .entry("mcpServers")
765            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
766            .as_object_mut()
767            .with_context(|| {
768                format!(
769                    "mcpServers in staged Claude configuration {} must be a JSON object",
770                    path.display()
771                )
772            })?;
773        servers.insert(
774            "mj-agents".into(),
775            serde_json::json!({
776                "type":"stdio",
777                "command":Path::new(worker_root).join("hel"),
778                "args":[
779                    "worker",
780                    "subagent-mcp",
781                    "--socket",
782                    Path::new(worker_root).join(mj_worker_socket_name())
783                ]
784            }),
785        );
786        Ok(())
787    })
788}
789
790/// Write the enforcement table's staged setting, if the harness has one. Muse
791/// composes a session's permission profile from its settings file and nothing
792/// on the ACP wire overrides that choice, so the profile has to be staged.
793fn apply_staged_execution_setting(
794    kind: mj_core::config::HarnessKind,
795    policy: mj_core::config::ExecutionPolicy,
796    profile_stage: &Path,
797) -> Result<()> {
798    let Some(setting) = kind
799        .execution_enforcement(policy)
800        .and_then(mj_core::config::ExecutionEnforcement::staged_setting)
801    else {
802        return Ok(());
803    };
804    let path = profile_stage.join(setting.file);
805    let label = format!("staged {} settings", kind.display_name());
806    edit_staged_json_object(&path, &label, |root| {
807        setting
808            .apply(root)
809            .with_context(|| format!("{label} {}", path.display()))
810    })
811}
812
813/// Read a staged JSON settings file (treating a missing file as an empty
814/// object), let `edit` change its root object, and write it back atomically.
815/// `label` names the file in every error message.
816fn edit_staged_json_object(
817    path: &Path,
818    label: &str,
819    edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> Result<()>,
820) -> Result<()> {
821    let mut document = match std::fs::read(path) {
822        Ok(body) => serde_json::from_slice::<serde_json::Value>(&body)
823            .with_context(|| format!("parse {label} {}", path.display()))?,
824        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
825            serde_json::Value::Object(serde_json::Map::new())
826        }
827        Err(error) => {
828            return Err(error).with_context(|| format!("read {label} {}", path.display()));
829        }
830    };
831    let root = document
832        .as_object_mut()
833        .with_context(|| format!("{label} {} must contain a JSON object", path.display()))?;
834    edit(root)?;
835    let mut body = serde_json::to_vec_pretty(&document)?;
836    body.push(b'\n');
837    atomic_write(path, &body).with_context(|| format!("write {label} {}", path.display()))
838}
839
840fn mj_worker_socket_name() -> &'static str {
841    "subagents.sock"
842}
843
844fn directory_has_files(path: &Path) -> Result<bool> {
845    let entries = match std::fs::read_dir(path) {
846        Ok(entries) => entries,
847        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
848        Err(error) => return Err(error.into()),
849    };
850    for entry in entries {
851        let entry = entry?;
852        let metadata = entry.metadata()?;
853        if metadata.is_file() || (metadata.is_dir() && directory_has_files(&entry.path())?) {
854            return Ok(true);
855        }
856    }
857    Ok(false)
858}
859
860#[derive(Debug, Clone, PartialEq, Eq)]
861pub enum WorkerBinaryAvailability {
862    Local {
863        path: PathBuf,
864        source: String,
865    },
866    Remote {
867        url: String,
868        sha256: String,
869        triple: String,
870    },
871}
872
873/// Sources captured before the daemon starts its managers and coordinators.
874///
875/// Local sources are copied into an immutable, content-addressed cache during
876/// capture. Remote sources retain only their URL, digest, and target triple;
877/// the network fetch still happens when a target is provisioned.
878#[derive(Debug)]
879struct WorkerBinarySourceSnapshot {
880    entries: HashMap<
881        (String, WorkerBinaryRequirement),
882        std::result::Result<WorkerBinaryAvailability, String>,
883    >,
884}
885
886static PINNED_WORKER_BINARY_SOURCES: OnceLock<WorkerBinarySourceSnapshot> = OnceLock::new();
887
888fn packaged_worker_binary_path(directory: &Path, triple: &str) -> PathBuf {
889    directory.join(format!("mj-worker-{triple}"))
890}
891
892/// Linux exposes an unlinked running executable through `/proc` with a
893/// ` (deleted)` suffix. `current_exe` preserves that suffix, but it is not
894/// part of the executable's real file name and must not leak into sibling
895/// lookup after `cargo` or a package upgrade replaces the controller.
896fn running_executable_file_name(controller: &Path) -> Option<std::ffi::OsString> {
897    let name = controller.file_name()?;
898    #[cfg(target_os = "linux")]
899    {
900        use std::os::unix::ffi::{OsStrExt, OsStringExt};
901
902        if let Some(name) = name.as_bytes().strip_suffix(b" (deleted)") {
903            return Some(std::ffi::OsString::from_vec(name.to_vec()));
904        }
905    }
906    Some(name.to_os_string())
907}
908
909/// File names a worker binary may carry when it sits beside the controller or
910/// in a development sibling directory. The controller's own file name comes
911/// first (after the 2.0 rename that is `mj`), then the legacy `hel` name that
912/// older packages shipped, so both resolve without hardcoding one.
913fn worker_sibling_names(controller: &Path) -> Vec<std::ffi::OsString> {
914    use std::ffi::OsString;
915    let mut names = Vec::new();
916    if let Some(own) = running_executable_file_name(controller) {
917        names.push(own);
918    }
919    let legacy = OsString::from("hel");
920    if !names.contains(&legacy) {
921        names.push(legacy);
922    }
923    names
924}
925
926/// A local-bare session runs on the controller host, so it may use the native
927/// worker built or packaged beside `mj`. Managed targets never consider this
928/// name because a macOS or glibc binary is not portable into Linux targets.
929fn select_native_worker(
930    controller: &Path,
931    is_file: impl Fn(&Path) -> bool,
932) -> Option<(PathBuf, &'static str)> {
933    let directory = controller.parent()?;
934    if let (Some(profile), Some(target_dir)) = (directory.file_name(), directory.parent()) {
935        let development_worker = target_dir.join("worker").join(profile).join("mj-worker");
936        if is_file(&development_worker) {
937            return Some((development_worker, "isolated native development worker"));
938        }
939    }
940    let packaged_worker = directory.join("mj-worker");
941    is_file(&packaged_worker).then_some((packaged_worker, "native worker beside mj"))
942}
943
944/// Choose a worker binary that ships beside the controller or in a development
945/// musl sibling directory. `is_file` probes the filesystem; tests pass a
946/// hand-written probe. The static musl sibling is probed before the worker in
947/// the controller's own directory, because in a development checkout that
948/// same-directory candidate resolves to the controller itself, whose glibc may
949/// be newer than the target's.
950fn select_sibling_worker(
951    controller: &Path,
952    triple: &str,
953    is_file: impl Fn(&Path) -> bool,
954) -> Option<(PathBuf, &'static str)> {
955    let directory = controller.parent()?;
956    let names = worker_sibling_names(controller);
957    let mut candidates: Vec<(PathBuf, &'static str)> = Vec::new();
958    // Packaged worker beside the controller, named for the target triple.
959    candidates.push((
960        packaged_worker_binary_path(directory, triple),
961        "beside the mj binary",
962    ));
963    // Development checkout: a controller at target/<profile>/<name> finds its
964    // musl sibling at target/<triple>/<profile>/<name>. The static build is
965    // preferred because the target's glibc may be older than the host's, so it
966    // is probed before the same-directory worker (which is the controller
967    // itself in a development checkout).
968    if let (Some(profile), Some(target_dir)) = (directory.file_name(), directory.parent()) {
969        candidates.push((
970            target_dir
971                .join("worker")
972                .join(triple)
973                .join(profile)
974                .join("mj-worker"),
975            "isolated development musl worker",
976        ));
977        candidates.push((
978            target_dir.join(triple).join(profile).join("mj-worker"),
979            "development musl worker",
980        ));
981        for name in &names {
982            candidates.push((
983                target_dir.join(triple).join(profile).join(name),
984                "development musl sibling",
985            ));
986        }
987    }
988    // A legacy package may put an `hel`-named worker beside an `mj`
989    // controller. Never select the controller's own same-directory path: on
990    // glibc Linux that is not a portable worker, and after an upgrade it is
991    // the replacement controller rather than the still-running executable.
992    let controller_name = running_executable_file_name(controller);
993    for name in names
994        .iter()
995        .filter(|name| Some(name.as_os_str()) != controller_name.as_deref())
996    {
997        candidates.push((directory.join(name), "beside the running executable"));
998    }
999    candidates.into_iter().find(|(path, _)| is_file(path))
1000}
1001
1002#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1003enum WorkerBinaryRequirement {
1004    PortableLinux,
1005    LocalHost,
1006}
1007
1008impl WorkerBinarySourceSnapshot {
1009    fn capture<F>(cache_root: &Path, resolve: F) -> Self
1010    where
1011        F: Fn(&str, WorkerBinaryRequirement) -> Result<WorkerBinaryAvailability>,
1012    {
1013        let mut entries = HashMap::new();
1014        let mut local_cache = HashMap::<PathBuf, PathBuf>::new();
1015        let architectures = [
1016            (std::env::consts::ARCH, WorkerBinaryRequirement::LocalHost),
1017            ("x86_64", WorkerBinaryRequirement::PortableLinux),
1018            ("aarch64", WorkerBinaryRequirement::PortableLinux),
1019        ];
1020
1021        for (arch, requirement) in architectures {
1022            let pinned = match resolve(arch, requirement) {
1023                Ok(WorkerBinaryAvailability::Local { path, source }) => {
1024                    match local_cache.get(&path).cloned().map(Ok).unwrap_or_else(|| {
1025                        copy_worker_source_to_cache(&path, cache_root).inspect(|cached| {
1026                            local_cache.insert(path.clone(), cached.clone());
1027                        })
1028                    }) {
1029                        Ok(cached) => Ok(WorkerBinaryAvailability::Local {
1030                            path: cached,
1031                            source,
1032                        }),
1033                        Err(error) => {
1034                            let error = format!(
1035                                "pin worker source {} for {arch} ({requirement:?}): {error:#}",
1036                                path.display()
1037                            );
1038                            tracing::warn!(arch, requirement = ?requirement, error = %error);
1039                            Err(error)
1040                        }
1041                    }
1042                }
1043                Ok(WorkerBinaryAvailability::Remote {
1044                    url,
1045                    sha256,
1046                    triple,
1047                }) => Ok(WorkerBinaryAvailability::Remote {
1048                    url,
1049                    sha256,
1050                    triple,
1051                }),
1052                Err(error) => {
1053                    let error = format!("{error:#}");
1054                    tracing::debug!(
1055                        arch,
1056                        requirement = ?requirement,
1057                        error = %error,
1058                        "worker source was unavailable when the daemon started"
1059                    );
1060                    Err(error)
1061                }
1062            };
1063            entries.insert((arch.to_owned(), requirement), pinned);
1064        }
1065
1066        Self { entries }
1067    }
1068
1069    fn resolve(
1070        &self,
1071        arch: &str,
1072        requirement: WorkerBinaryRequirement,
1073    ) -> Result<WorkerBinaryAvailability> {
1074        let Some(source) = self.entries.get(&(arch.to_owned(), requirement)) else {
1075            bail!(
1076                "worker source for {arch} ({requirement:?}) was not captured when the daemon started"
1077            );
1078        };
1079        match source {
1080            Ok(availability) => Ok(availability.clone()),
1081            Err(error) => bail!(
1082                "worker source for {arch} ({requirement:?}) was unavailable when the daemon started; install it and restart the daemon to retry: {error}"
1083            ),
1084        }
1085    }
1086}
1087
1088/// Capture the worker sources used by this daemon before its asynchronous
1089/// managers start. Missing sources are retained as per-architecture errors so
1090/// an unused architecture does not prevent daemon startup.
1091pub fn pin_worker_binary_sources() -> Result<()> {
1092    if PINNED_WORKER_BINARY_SOURCES.get().is_some() {
1093        return Ok(());
1094    }
1095    let current = std::env::current_exe().context("resolve Mjolnir controller binary")?;
1096    let cache_root = data_dir().join("workers").join("pinned");
1097    let started = std::time::Instant::now();
1098    let snapshot = WorkerBinarySourceSnapshot::capture(&cache_root, |arch, requirement| {
1099        worker_binary_prerequisite_for_current(arch, requirement, &current, &|path| path.is_file())
1100    });
1101    tracing::info!(
1102        elapsed_ms = started.elapsed().as_millis(),
1103        "worker sources pinned"
1104    );
1105    // The daemon boot path calls this once. If a second caller races it, keep
1106    // the first complete snapshot and never replace paths it may already use.
1107    let _ = PINNED_WORKER_BINARY_SOURCES.set(snapshot);
1108    Ok(())
1109}
1110
1111fn copy_worker_source_to_cache(source: &Path, cache_root: &Path) -> Result<PathBuf> {
1112    std::fs::create_dir_all(cache_root)
1113        .with_context(|| format!("create pinned worker cache {}", cache_root.display()))?;
1114    let mut input =
1115        File::open(source).with_context(|| format!("open worker source {}", source.display()))?;
1116    let metadata = input
1117        .metadata()
1118        .with_context(|| format!("stat worker source {}", source.display()))?;
1119    let mut temporary = tempfile::NamedTempFile::new_in(cache_root)
1120        .with_context(|| format!("create pinned worker staging file {}", cache_root.display()))?;
1121    let mut digest = Sha256::new();
1122    let mut buffer = [0_u8; 128 * 1024];
1123    loop {
1124        let count = input
1125            .read(&mut buffer)
1126            .with_context(|| format!("read worker source {}", source.display()))?;
1127        if count == 0 {
1128            break;
1129        }
1130        temporary
1131            .write_all(&buffer[..count])
1132            .with_context(|| format!("copy worker source {}", source.display()))?;
1133        digest.update(&buffer[..count]);
1134    }
1135    temporary
1136        .as_file_mut()
1137        .sync_all()
1138        .with_context(|| format!("flush pinned worker source {}", source.display()))?;
1139    std::fs::set_permissions(temporary.path(), metadata.permissions())
1140        .with_context(|| format!("preserve permissions for {}", source.display()))?;
1141    let digest = format!("{:x}", digest.finalize());
1142    publish_cached_worker(temporary, cache_root, &digest)
1143}
1144
1145/// Publish one immutable cache artifact. persist_noclobber makes the final
1146/// publication atomic and never replaces an artifact another daemon may have
1147/// already captured.
1148fn publish_cached_worker(
1149    temporary: tempfile::NamedTempFile,
1150    cache_root: &Path,
1151    digest: &str,
1152) -> Result<PathBuf> {
1153    let directory = cache_root.join(digest);
1154    std::fs::create_dir_all(&directory)
1155        .with_context(|| format!("create pinned worker cache {}", directory.display()))?;
1156    let destination = directory.join("hel");
1157    if destination.is_file() {
1158        return Ok(destination);
1159    }
1160    match temporary.persist_noclobber(&destination) {
1161        Ok(_) => {
1162            #[cfg(unix)]
1163            File::open(&directory)
1164                .and_then(|directory| directory.sync_all())
1165                .with_context(|| format!("flush pinned worker cache {}", directory.display()))?;
1166            Ok(destination)
1167        }
1168        Err(error) if error.error.kind() == ErrorKind::AlreadyExists => {
1169            if destination.is_file() {
1170                Ok(destination)
1171            } else {
1172                Err(error.error).with_context(|| {
1173                    format!("publish pinned worker artifact {}", destination.display())
1174                })
1175            }
1176        }
1177        Err(error) => Err(error.error)
1178            .with_context(|| format!("publish pinned worker artifact {}", destination.display())),
1179    }
1180}
1181
1182/// Find a worker source without downloading it.
1183///
1184/// Container provisioning resolves this after discovering the target
1185/// architecture. Doctor uses the same lookup with the selected container's
1186/// expected architecture, so it can recommend a fix without creating a
1187/// container or making a network request.
1188pub fn worker_binary_prerequisite_for_arch(arch: &str) -> Result<WorkerBinaryAvailability> {
1189    worker_binary_for_arch(arch, WorkerBinaryRequirement::PortableLinux)
1190}
1191
1192fn worker_binary_for_arch(
1193    arch: &str,
1194    requirement: WorkerBinaryRequirement,
1195) -> Result<WorkerBinaryAvailability> {
1196    if let Some(snapshot) = PINNED_WORKER_BINARY_SOURCES.get() {
1197        return snapshot.resolve(arch, requirement);
1198    }
1199    let current = std::env::current_exe().context("resolve Mjolnir controller binary")?;
1200    worker_binary_prerequisite_for_current(arch, requirement, &current, &|path| path.is_file())
1201}
1202
1203/// The lookup itself, with the controller's own path and the file probe passed
1204/// in so both can be exercised without the machine they describe.
1205fn worker_binary_prerequisite_for_current(
1206    arch: &str,
1207    requirement: WorkerBinaryRequirement,
1208    current: &Path,
1209    is_file: &dyn Fn(&Path) -> bool,
1210) -> Result<WorkerBinaryAvailability> {
1211    let triple = format!("{arch}-unknown-linux-musl");
1212    if let Some(path) = mj_core::config::env_override_os("WORKER_BINARY").map(PathBuf::from) {
1213        if !is_file(&path) {
1214            bail!("MJ_WORKER_BINARY is not a file: {}", path.display());
1215        }
1216        return Ok(WorkerBinaryAvailability::Local {
1217            path,
1218            source: "MJ_WORKER_BINARY".into(),
1219        });
1220    }
1221    // A rebuilt or renamed checkout leaves a running controller pointing at a
1222    // path that no longer holds a binary. Every lookup derived from that path
1223    // is meaningless, so remember the fact and skip those lookups.
1224    let controller_replaced = !is_file(current);
1225    let mut candidates = Vec::new();
1226    if let Some(directory) = mj_core::config::env_override_os("WORKER_DIR").map(PathBuf::from) {
1227        candidates.push((
1228            packaged_worker_binary_path(&directory, &triple),
1229            "MJ_WORKER_DIR",
1230        ));
1231        candidates.push((directory.join(&triple).join("hel"), "MJ_WORKER_DIR"));
1232    }
1233    if let Some((path, source)) = candidates.into_iter().find(|(path, _)| is_file(path)) {
1234        return Ok(WorkerBinaryAvailability::Local {
1235            path,
1236            source: source.into(),
1237        });
1238    }
1239    if requirement == WorkerBinaryRequirement::LocalHost
1240        && let Some((path, source)) = select_native_worker(current, is_file)
1241    {
1242        return Ok(WorkerBinaryAvailability::Local {
1243            path,
1244            source: source.into(),
1245        });
1246    }
1247    if !controller_replaced
1248        && let Some((path, source)) = select_sibling_worker(current, &triple, is_file)
1249    {
1250        return Ok(WorkerBinaryAvailability::Local {
1251            path,
1252            source: source.into(),
1253        });
1254    }
1255    if let Some(template) = mj_core::config::env_override("WORKER_URL") {
1256        let expected = mj_core::config::env_override("WORKER_SHA256")
1257            .context("MJ_WORKER_URL requires MJ_WORKER_SHA256")?;
1258        validate_worker_sha256(&expected)?;
1259        return Ok(WorkerBinaryAvailability::Remote {
1260            url: template.replace("{target}", &triple),
1261            sha256: expected,
1262            triple,
1263        });
1264    }
1265    // Telling someone to install a worker beside a binary that is no longer
1266    // there sends them looking in the wrong place.
1267    ensure!(
1268        !controller_replaced,
1269        "the running mj binary was replaced or removed on disk ({}); restart the Mjolnir daemon so it runs the current build, then retry",
1270        display_path(current)
1271    );
1272    bail!(
1273        "no Linux worker for {triple}; install mj-worker-{triple} beside mj, set MJ_WORKER_DIR/MJ_WORKER_BINARY, or configure MJ_WORKER_URL and MJ_WORKER_SHA256"
1274    )
1275}
1276
1277/// Linux appends " (deleted)" to `/proc/<pid>/exe` for a removed image. That
1278/// marker belongs in a message but never in a decision, which `is_file` makes.
1279fn display_path(path: &Path) -> String {
1280    let text = path.to_string_lossy();
1281    text.strip_suffix(" (deleted)").unwrap_or(&text).to_owned()
1282}
1283
1284/// The architecture a configured template names outright, if it names one. A
1285/// container `platform` such as `linux/arm64` decides what the target runs
1286/// whatever the controller's own machine is, and it is the only architecture a
1287/// configured target template can state: the configured `AwsEc2` variant names
1288/// a launch template, whose instance type is only discoverable through the AWS
1289/// API.
1290fn template_architecture(template: &mj_core::config::TargetTemplate) -> Option<&'static str> {
1291    use mj_core::config::TargetTemplate as Template;
1292    let platform = match template {
1293        Template::LocalPodman { container }
1294        | Template::LocalDocker { container }
1295        | Template::AppleContainer { container }
1296        | Template::SshPodman { container, .. }
1297        | Template::SshDocker { container, .. } => container.platform.as_deref()?,
1298        Template::LocalBare | Template::SshBare { .. } | Template::AwsEc2 { .. } => return None,
1299    };
1300    // Platform strings appear as "linux/arm64", "arm64", or "linux/arm64/v8".
1301    platform.split('/').find_map(|part| match part.trim() {
1302        "x86_64" | "amd64" => Some("x86_64"),
1303        "aarch64" | "arm64" => Some("aarch64"),
1304        _ => None,
1305    })
1306}
1307
1308/// Architectures a resume must be able to serve, knowing only the configured
1309/// template. Provisioning learns the real answer by running `uname -m` on the
1310/// live target; a resume has no target yet, so this uses what is knowable
1311/// without one: an architecture the template names, else the controller's own
1312/// architecture for a target that runs on this machine, else either Linux
1313/// architecture for a remote target.
1314fn preflight_architectures(template: &mj_core::config::TargetTemplate) -> Vec<&'static str> {
1315    use mj_core::config::TargetTemplate as Template;
1316    if let Some(arch) = template_architecture(template) {
1317        return vec![arch];
1318    }
1319    match template {
1320        Template::LocalBare
1321        | Template::LocalPodman { .. }
1322        | Template::LocalDocker { .. }
1323        | Template::AppleContainer { .. } => vec![std::env::consts::ARCH],
1324        Template::SshBare { .. }
1325        | Template::SshPodman { .. }
1326        | Template::SshDocker { .. }
1327        | Template::AwsEc2 { .. } => {
1328            vec!["x86_64", "aarch64"]
1329        }
1330    }
1331}
1332
1333/// Whether this controller could produce a Linux worker binary for a target
1334/// that does not exist yet.
1335///
1336/// A resume compacts a cross-harness transcript before it provisions anything,
1337/// which costs minutes and paid model requests. Resolving the worker binary is
1338/// local and takes microseconds, so a resume that could never install a worker
1339/// must fail before spending any of that. This downloads nothing: a remote
1340/// source counts as available, because fetching it belongs to provisioning.
1341pub(super) fn preflight_worker_binary(template: &mj_core::config::TargetTemplate) -> Result<()> {
1342    // Only a bare local target may run the controller's own host binary as
1343    // its worker; every other target needs a portable Linux worker.
1344    let requirement = if matches!(template, mj_core::config::TargetTemplate::LocalBare) {
1345        WorkerBinaryRequirement::LocalHost
1346    } else {
1347        WorkerBinaryRequirement::PortableLinux
1348    };
1349    let mut failure = None;
1350    for arch in preflight_architectures(template) {
1351        match worker_binary_for_arch(arch, requirement) {
1352            Ok(_) => return Ok(()),
1353            Err(error) => failure = Some(error),
1354        }
1355    }
1356    match failure {
1357        // The message is the one provisioning would have printed later, so the
1358        // user reads the same fix, sooner.
1359        Some(error) => Err(error).context("preflight the worker binary before resuming"),
1360        None => Ok(()),
1361    }
1362}
1363
1364pub(super) fn worker_binary_for(
1365    locator: &targets::TargetLocator,
1366    executor: &impl CommandExecutor,
1367) -> Result<PathBuf> {
1368    let arch = target_architecture(locator, executor)?;
1369    let requirement = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
1370        WorkerBinaryRequirement::LocalHost
1371    } else {
1372        WorkerBinaryRequirement::PortableLinux
1373    };
1374    match worker_binary_for_arch(arch, requirement)? {
1375        WorkerBinaryAvailability::Local { path, .. } => Ok(path),
1376        WorkerBinaryAvailability::Remote {
1377            url,
1378            sha256,
1379            triple,
1380        } => download_worker(&url, &sha256, &triple),
1381    }
1382}
1383
1384fn target_architecture(
1385    locator: &targets::TargetLocator,
1386    executor: &impl CommandExecutor,
1387) -> Result<&'static str> {
1388    let command = match locator {
1389        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("uname", ["-m"]),
1390        targets::TargetLocator::LocalPodman { container_id, .. } => {
1391            CommandSpec::new("podman", ["exec", container_id, "uname", "-m"])
1392        }
1393        targets::TargetLocator::LocalDocker { container_id } => {
1394            CommandSpec::new("docker", ["exec", container_id, "uname", "-m"])
1395        }
1396        targets::TargetLocator::AppleContainer { container_id } => {
1397            CommandSpec::new("container", ["exec", container_id, "uname", "-m"])
1398        }
1399        targets::TargetLocator::AwsEc2 { ssh, .. }
1400        | targets::TargetLocator::SshBare { ssh, .. } => ssh_command_spec(ssh, ["uname", "-m"]),
1401        targets::TargetLocator::SshPodman {
1402            ssh, container_id, ..
1403        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "uname", "-m"]),
1404        targets::TargetLocator::SshDocker { ssh, container_id } => {
1405            ssh_command_spec(ssh, ["docker", "exec", container_id, "uname", "-m"])
1406        }
1407    }
1408    .purpose("detect target architecture");
1409    let output = execute_checked(executor, command)?;
1410    match String::from_utf8(output.stdout)?.trim() {
1411        "x86_64" | "amd64" => Ok("x86_64"),
1412        "aarch64" | "arm64" => Ok("aarch64"),
1413        architecture => bail!("unsupported target architecture {architecture:?}"),
1414    }
1415}
1416
1417fn download_worker(url: &str, expected_sha256: &str, triple: &str) -> Result<PathBuf> {
1418    validate_worker_sha256(expected_sha256)?;
1419    let digest = expected_sha256.to_ascii_lowercase();
1420    let directory = data_dir().join("workers").join("pinned");
1421    let destination = directory.join(&digest).join("hel");
1422    std::fs::create_dir_all(destination.parent().unwrap_or(&directory))?;
1423    if destination.is_file() {
1424        let bytes = std::fs::read(&destination).with_context(|| {
1425            format!(
1426                "read cached worker for {triple} from {}",
1427                destination.display()
1428            )
1429        })?;
1430        if format!("{:x}", Sha256::digest(&bytes)).eq_ignore_ascii_case(expected_sha256) {
1431            return Ok(destination);
1432        }
1433        bail!(
1434            "content-addressed worker cache {} does not match {} checksum",
1435            destination.display(),
1436            expected_sha256
1437        );
1438    }
1439    let bytes = reqwest::blocking::Client::builder()
1440        .timeout(std::time::Duration::from_secs(120))
1441        .build()?
1442        .get(url)
1443        .send()?
1444        .error_for_status()?
1445        .bytes()?;
1446    let actual = format!("{:x}", Sha256::digest(&bytes));
1447    if !actual.eq_ignore_ascii_case(expected_sha256) {
1448        bail!("downloaded worker checksum mismatch: expected {expected_sha256}, got {actual}");
1449    }
1450    std::fs::create_dir_all(&directory)?;
1451    let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
1452    std::io::Write::write_all(&mut temporary, &bytes)?;
1453    temporary.as_file_mut().sync_all()?;
1454    #[cfg(unix)]
1455    {
1456        use std::os::unix::fs::PermissionsExt;
1457        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
1458    }
1459    publish_cached_worker(temporary, &directory, &digest)
1460}
1461
1462fn validate_worker_sha256(expected_sha256: &str) -> Result<()> {
1463    if expected_sha256.len() != 64 || !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
1464    {
1465        bail!("MJ_WORKER_SHA256 must be a 64-character hexadecimal digest");
1466    }
1467    Ok(())
1468}
1469
1470fn workspace_paths(
1471    locator: &targets::TargetLocator,
1472    bundle: &ProjectBundle,
1473    session_id: &str,
1474) -> Result<(String, Vec<String>)> {
1475    let root = match locator {
1476        targets::TargetLocator::LocalBare { .. } => {
1477            bail!("local bare projects use their selected directory")
1478        }
1479        targets::TargetLocator::LocalPodman { .. }
1480        | targets::TargetLocator::LocalDocker { .. }
1481        | targets::TargetLocator::AppleContainer { .. }
1482        | targets::TargetLocator::SshPodman { .. }
1483        | targets::TargetLocator::SshDocker { .. } => "/workspace".to_string(),
1484        targets::TargetLocator::AwsEc2 { workspace, .. }
1485        | targets::TargetLocator::SshBare { workspace, .. } => workspace.clone(),
1486    };
1487    if matches!(locator, targets::TargetLocator::AwsEc2 { .. }) {
1488        let expected = format!(".local/share/hel/workspaces/{session_id}");
1489        if root != expected {
1490            bail!("AWS workspace does not match session")
1491        }
1492    }
1493    let primary = bundle.primary().context("bundle primary is missing")?;
1494    let primary_path = format!("{root}/{}", primary.destination.to_string_lossy());
1495    let additional = bundle
1496        .repositories
1497        .iter()
1498        .filter(|repository| repository.id != bundle.primary_repo)
1499        .map(|repository| format!("{root}/{}", repository.destination.to_string_lossy()))
1500        .collect();
1501    Ok((primary_path, additional))
1502}
1503
1504// Package versions for ACP bridges and their harnesses. Keep these in lockstep with the global
1505// npm installs in containers/Containerfile.agent-dev; bridge_pins_match_containerfile() below
1506// fails the build when they drift.
1507// Codex 0.148 reuses pending MCP startups during runtime reconciliation. Older
1508// releases could cancel the first project-memory startup while immediately
1509// replacing it with an equivalent connection, leaving a false failed-tool
1510// event at the beginning of every session.
1511/// Stage shown after the worker is reachable and while its ACP bridge becomes
1512/// ready. Every remaining harness launches through a default launcher that can
1513/// fetch it, so the stage names the harness being installed.
1514pub(super) fn bridge_readiness_stage(profile: &HarnessProfile) -> ProvisionStage {
1515    ProvisionStage::Installing(profile.kind)
1516}
1517
1518pub(super) fn bridge_launch(
1519    harness: mj_core::config::HarnessKind,
1520    policy: mj_core::config::ExecutionPolicy,
1521) -> (String, Vec<String>) {
1522    match harness {
1523        mj_core::config::HarnessKind::Muse => ("muse-acp".into(), Vec::new()),
1524        mj_core::config::HarnessKind::Codex => (
1525            "sh".into(),
1526            vec![
1527                "-c".into(),
1528                format!("if command -v codex-acp >/dev/null 2>&1 && [ \"$(codex-acp --version 2>/dev/null)\" = \"{CODEX_ACP_PACKAGE} {CODEX_ACP_VERSION}\" ]; then exec codex-acp; fi; {}; exec npx -y {CODEX_ACP_PACKAGE}@{CODEX_ACP_VERSION}", ensure_node_script()),
1529            ],
1530        ),
1531        mj_core::config::HarnessKind::Claude => (
1532            "sh".into(),
1533            vec![
1534                "-c".into(),
1535                format!("if command -v claude-agent-acp >/dev/null 2>&1; then exec claude-agent-acp; fi; {}; exec npx -y @agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}", ensure_node_script()),
1536            ],
1537        ),
1538        mj_core::config::HarnessKind::Kimi => (
1539            "sh".into(),
1540            vec![
1541                "-c".into(),
1542                "if command -v kimi >/dev/null 2>&1; then exec kimi acp; elif [ -x \"$HOME/.kimi-code/bin/kimi\" ]; then exec \"$HOME/.kimi-code/bin/kimi\" acp; elif command -v curl >/dev/null 2>&1; then curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash && exec \"$HOME/.kimi-code/bin/kimi\" acp; else echo 'Mjolnir needs compatible Kimi Code or curl for its official installer; add the tool to PATH' >&2; exit 127; fi".into(),
1543            ],
1544        ),
1545        mj_core::config::HarnessKind::Grok => {
1546            let acp = mj_core::config::HarnessKind::Grok
1547                .bridge_args(policy)
1548                .join(" ");
1549            (
1550                "sh".into(),
1551                vec![
1552                    "-c".into(),
1553                    format!(
1554                        "if command -v grok >/dev/null 2>&1; then exec grok {acp}; elif [ -x \"$GROK_HOME/bin/grok\" ]; then exec \"$GROK_HOME/bin/grok\" {acp}; elif [ -x \"$HOME/.grok/bin/grok\" ]; then exec \"$HOME/.grok/bin/grok\" {acp}; elif command -v curl >/dev/null 2>&1; then curl -fsSL https://x.ai/cli/install.sh | bash && exec \"$HOME/.grok/bin/grok\" {acp}; else echo 'Mjolnir needs compatible Grok Build or curl for its official installer; add the tool to PATH' >&2; exit 127; fi"
1555                    ),
1556                ],
1557            )
1558        }
1559    }
1560}
1561
1562pub(super) fn preflight_harness(
1563    template: &mj_core::config::TargetTemplate,
1564    profile: &HarnessProfile,
1565    executor: &impl CommandExecutor,
1566) -> Result<()> {
1567    use mj_core::config::TargetTemplate;
1568    if !matches!(profile.kind, HarnessKind::Codex | HarnessKind::Claude) {
1569        return Ok(());
1570    }
1571    if !matches!(
1572        template,
1573        TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
1574    ) {
1575        return Ok(());
1576    }
1577    let script = "if ! command -v node >/dev/null 2>&1; then echo 'Node.js is missing from PATH; install Node.js 22 or newer in the target environment' >&2; exit 127; fi; if ! node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= 22 ? 0 : 1)'; then echo 'Node.js 22 or newer is required in the target environment' >&2; exit 1; fi; if ! command -v npm >/dev/null 2>&1 || ! npm --version >/dev/null; then echo 'npm is missing or unusable; install npm in the target environment' >&2; exit 127; fi";
1578    let mut args = if profile.environment.contains_key("PATH") {
1579        vec![
1580            "-c".to_owned(),
1581            format!("export PATH=\"$1\"; {script}"),
1582            "mj-node-preflight".into(),
1583            profile.environment["PATH"].clone(),
1584        ]
1585    } else {
1586        vec!["-lc".to_owned(), script.to_owned()]
1587    };
1588    let (command, destination) = match template {
1589        TargetTemplate::LocalBare => (CommandSpec::new("sh", args), "local host".to_owned()),
1590        TargetTemplate::SshBare { ssh, .. } => {
1591            let ssh = super::backend_ssh(ssh);
1592            args.insert(0, "sh".into());
1593            (ssh_command_spec(&ssh, args), ssh.destination)
1594        }
1595        _ => unreachable!(),
1596    };
1597    execute_checked(executor, command.purpose("preflight managed harness Node.js and npm"))
1598        .with_context(|| format!("{} launch preflight failed on {destination}; Node.js 22+ and npm must be available on the target PATH", profile.kind.display_name()))?;
1599    Ok(())
1600}
1601
1602fn ensure_node_script() -> &'static str {
1603    "if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1; then echo 'Mjolnir needs Node.js, npm, and npx on PATH; install Node in the target environment' >&2; exit 127; fi"
1604}
1605
1606const MJ_CONTAINER_ENVIRONMENT: &str = "## Mjolnir disposable environment\n\nThis session runs in a disposable Mjolnir container. When the session closes, Mjolnir checkpoints everything in project workspace directories under `/workspace`, including committed work, staged and unstaged changes, and untracked files. Mjolnir then removes the container.\n\nEverything outside `/workspace`, including installed packages, `$HOME`, and `/tmp`, is ephemeral and will be lost. Keep durable results in the workspace or push them to a remote.\n\nNew workspaces start on their own session branch from the default network fetch remote’s default branch. Local unpublished commits and uncommitted files are not copied. Use normal git push to publish the current branch to the configured network push destination. Closing saves a checkpoint; it does not publish commits or update the original local checkout. Resumed sessions restore their saved work.\n";
1607
1608pub(super) fn stage_profile(
1609    profile: &mj_core::config::HarnessProfile,
1610    destination: &Path,
1611) -> Result<()> {
1612    let harness = profile.kind;
1613    let source = profile.home.as_path();
1614    std::fs::create_dir_all(destination)?;
1615    let allowlist: &[&str] = match harness {
1616        mj_core::config::HarnessKind::Muse => &[
1617            "auth.json",
1618            "settings.json",
1619            "trust.json",
1620            "AGENTS.md",
1621            "skills",
1622            "rules",
1623        ],
1624        mj_core::config::HarnessKind::Codex => &[
1625            "auth.json",
1626            "config.toml",
1627            "AGENTS.md",
1628            "instructions.md",
1629            "rules",
1630            "skills",
1631        ],
1632        mj_core::config::HarnessKind::Claude => &[
1633            ".claude.json",
1634            ".credentials.json",
1635            "settings.json",
1636            "CLAUDE.md",
1637            "skills",
1638            "plugins",
1639        ],
1640        mj_core::config::HarnessKind::Kimi => &[
1641            "credentials",
1642            "config.toml",
1643            "device_id",
1644            "AGENTS.md",
1645            "SYSTEM.md",
1646            "mcp.json",
1647            "skills",
1648            "agents",
1649            "plugins",
1650        ],
1651        mj_core::config::HarnessKind::Grok => &[
1652            "auth.json",
1653            "config.toml",
1654            "AGENTS.md",
1655            "agent_id",
1656            "skills",
1657            "plugins",
1658        ],
1659    };
1660    // Allowlist entries (and, within each, a copied directory's children) are
1661    // independent of one another, so copying them concurrently shortens the
1662    // stage step for profiles with large skills/plugins trees.
1663    allowlist.par_iter().try_for_each(|name| -> Result<()> {
1664        let from = source.join(name);
1665        if from.exists() {
1666            copy_profile_entry(&from, &destination.join(name))?;
1667        }
1668        Ok(())
1669    })?;
1670    Ok(())
1671}
1672
1673/// Fetches a provider's model catalog. A function parameter so tests can supply
1674/// a body without reaching the network. Takes the catalog URL and the API key;
1675/// returns the raw response body.
1676pub(super) type CatalogFetch<'a> = &'a dyn Fn(&str, &str) -> Result<Vec<u8>>;
1677
1678/// The catalog file Mjolnir writes into a staged Codex home, and the key it
1679/// points `config.toml` at. Relative to `CODEX_HOME`, so the staged copy works
1680/// unchanged on any target.
1681const STAGED_CATALOG_FILE: &str = "models.json";
1682
1683/// Where a fetched catalog is remembered so a provider outage cannot block a
1684/// launch. The live store is one implementation; a test can supply another.
1685pub(super) trait CatalogCache {
1686    fn load(&self, profile_id: &str, fingerprint: &str) -> Option<String>;
1687    fn store(&self, profile_id: &str, fingerprint: &str, body: &str);
1688}
1689
1690/// The catalog cache backed by Mjolnir's own `profile_config_cache` table.
1691pub(super) struct SharedCatalogCache;
1692
1693impl CatalogCache for SharedCatalogCache {
1694    fn load(&self, profile_id: &str, fingerprint: &str) -> Option<String> {
1695        crate::database::load_profile_config_cache(profile_id, "", fingerprint)
1696            .ok()
1697            .flatten()
1698    }
1699
1700    fn store(&self, profile_id: &str, fingerprint: &str, body: &str) {
1701        if let Err(error) = crate::database::save_profile_config_cache(
1702            profile_id.to_owned(),
1703            String::new(),
1704            fingerprint.to_owned(),
1705            body.to_owned(),
1706        ) {
1707            tracing::warn!(profile_id, "could not cache the model catalog: {error:#}");
1708        }
1709    }
1710}
1711
1712/// Give a staged Codex home the model catalog its provider advertises.
1713///
1714/// Codex fetches its model list from its own service only for ChatGPT logins.
1715/// Without a catalog file a profile pointed at another provider would offer
1716/// OpenAI's built-in model names and send them to that provider, so Mjolnir
1717/// fetches the provider's own catalog, stamps the Guardian reviewer on every
1718/// entry, writes it beside the staged `config.toml`, and points the staged
1719/// configuration at it.
1720///
1721/// Profiles with no custom provider are left alone. A failed fetch falls back to
1722/// the last catalog stored for this profile, so a provider outage does not block
1723/// a launch; with neither, the launch fails naming the profile and the URL.
1724pub(super) fn stage_codex_catalog(
1725    profile_id: &str,
1726    profile: &mj_core::config::HarnessProfile,
1727    destination: &Path,
1728    fetch: CatalogFetch<'_>,
1729    cache: &dyn CatalogCache,
1730) -> Result<()> {
1731    let Some(provider) = profile.codex_provider()? else {
1732        return Ok(());
1733    };
1734    let Some(env_key) = provider.env_key.as_deref() else {
1735        // An inline `experimental_bearer_token` provider carries its key in the
1736        // staged file itself; Mjolnir has no key of its own to authorize a
1737        // catalog fetch with.
1738        return Ok(());
1739    };
1740    let api_key = profile.environment.get(env_key).with_context(|| {
1741        format!("profile {profile_id:?} has no {env_key} entry to read its model catalog with")
1742    })?;
1743    let url = format!("{}/models", provider.base_url.trim_end_matches('/'));
1744    let fingerprint = format!("catalog:{}", provider.base_url);
1745    let body = match fetch(&url, api_key) {
1746        Ok(body) => {
1747            if let Ok(text) = std::str::from_utf8(&body) {
1748                cache.store(profile_id, &fingerprint, text);
1749            }
1750            body
1751        }
1752        Err(error) => match cache.load(profile_id, &fingerprint) {
1753            Some(body) => {
1754                tracing::warn!(
1755                    profile_id,
1756                    provider = %provider.id,
1757                    "could not fetch the model catalog from {url}, using the last cached copy: {error:#}"
1758                );
1759                body.into_bytes()
1760            }
1761            None => bail!(
1762                "profile {profile_id:?}: could not fetch the model catalog from {url} and no cached copy is available: {error:#}"
1763            ),
1764        },
1765    };
1766    let mut catalog = mj_core::codex_catalog::parse(&body)
1767        .with_context(|| format!("profile {profile_id:?}: model catalog from {url}"))?;
1768    apply_catalog_overrides(profile_id, &profile.home, &mut catalog)?;
1769    stamp_guardian_reviewer(profile_id, profile, &mut catalog)?;
1770    std::fs::create_dir_all(destination)?;
1771    std::fs::write(destination.join(STAGED_CATALOG_FILE), catalog.to_json())?;
1772    point_config_at_catalog(&destination.join("config.toml"))
1773}
1774
1775/// Refine the fetched catalog with the user's own `models.json`, when the
1776/// profile home has one.
1777///
1778/// A provider that serves OpenAI's plain model list gives Mjolnir only model
1779/// ids, so the translated entries carry conservative defaults. The override
1780/// file is how a user states what that provider actually supports: each entry
1781/// is matched by `slug` and its fields are copied over the fetched entry, and a
1782/// slug the provider did not list is added. Mjolnir writes the merged result
1783/// over the staged `models.json`, so the user's own file never reaches Codex
1784/// unmerged.
1785fn apply_catalog_overrides(
1786    profile_id: &str,
1787    home: &Path,
1788    catalog: &mut mj_core::codex_catalog::CodexCatalog,
1789) -> Result<()> {
1790    let path = home.join(STAGED_CATALOG_FILE);
1791    let bytes = match std::fs::read(&path) {
1792        Ok(bytes) => bytes,
1793        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1794        Err(error) => {
1795            return Err(error).with_context(|| format!("read {}", path.display()));
1796        }
1797    };
1798    let overrides = mj_core::codex_catalog::parse_codex_shape(&bytes).with_context(|| {
1799        format!(
1800            "profile {profile_id:?}: model catalog overrides in {}",
1801            path.display()
1802        )
1803    })?;
1804    mj_core::codex_catalog::merge_overrides(catalog, &overrides);
1805    Ok(())
1806}
1807
1808/// Record the Guardian reviewer choice on every catalog entry.
1809///
1810/// Codex reads the reviewer from the session model's own catalog entry, so the
1811/// override is stamped on all of them. The profile setting decides which model
1812/// that is: the newest flash model by default, the session model itself when
1813/// the setting is `session` (nothing is stamped, which is Codex's own
1814/// fallback), or a named slug. A named slug the catalog does not list fails the
1815/// launch, because stamping it would leave Codex silently reviewing with
1816/// something else.
1817fn stamp_guardian_reviewer(
1818    profile_id: &str,
1819    profile: &mj_core::config::HarnessProfile,
1820    catalog: &mut mj_core::codex_catalog::CodexCatalog,
1821) -> Result<()> {
1822    let setting = profile
1823        .guardian_review_model
1824        .as_deref()
1825        .unwrap_or(mj_core::config::GUARDIAN_REVIEW_NEWEST_FLASH);
1826    if setting == mj_core::config::GUARDIAN_REVIEW_SESSION {
1827        tracing::info!(
1828            profile_id,
1829            "guardian_review_model is \"session\"; Guardian reviews run on the session model"
1830        );
1831        return Ok(());
1832    }
1833    if setting == mj_core::config::GUARDIAN_REVIEW_NEWEST_FLASH {
1834        match mj_core::codex_catalog::guardian_review_model(catalog.slugs()) {
1835            Some(reviewer) => mj_core::codex_catalog::stamp_reviewer(catalog, &reviewer),
1836            // With no small model to review with, Codex falls back to reviewing
1837            // with the session model, which still runs Guardian.
1838            None => tracing::info!(
1839                profile_id,
1840                "the model catalog lists no flash model; Guardian reviews run on the session model"
1841            ),
1842        }
1843        return Ok(());
1844    }
1845    let slugs = catalog.slugs();
1846    if !slugs.iter().any(|slug| slug == setting) {
1847        bail!(
1848            "profile {profile_id:?}: guardian_review_model {setting:?} is not in the provider's model catalog, which lists {}",
1849            slugs.join(", ")
1850        );
1851    }
1852    mj_core::codex_catalog::stamp_reviewer(catalog, setting);
1853    Ok(())
1854}
1855
1856/// Prepend `model_catalog_json` to a staged Codex `config.toml`.
1857///
1858/// The key is top-level in Codex's configuration, and TOML puts every top-level
1859/// key before the first table header, so the line goes at the front. Appending
1860/// would make it a key of whichever table happens to come last, which Codex
1861/// ignores. Profile validation guarantees the user wrote no such key.
1862fn point_config_at_catalog(path: &Path) -> Result<()> {
1863    let existing = std::fs::read_to_string(path).unwrap_or_default();
1864    std::fs::write(
1865        path,
1866        format!("model_catalog_json = \"{STAGED_CATALOG_FILE}\"\n{existing}"),
1867    )
1868    .with_context(|| format!("point {} at the staged model catalog", path.display()))
1869}
1870
1871/// Fetch a provider's catalog over HTTPS. Mirrors the bounded client the Coding
1872/// Plan quota reader uses: a short timeout and no redirects.
1873pub(super) fn fetch_catalog_over_https(url: &str, api_key: &str) -> Result<Vec<u8>> {
1874    let response = reqwest::blocking::Client::builder()
1875        .timeout(std::time::Duration::from_secs(8))
1876        .redirect(reqwest::redirect::Policy::none())
1877        .build()?
1878        .get(url)
1879        .bearer_auth(api_key)
1880        .header(reqwest::header::ACCEPT, "application/json")
1881        .send()?
1882        .error_for_status()?;
1883    Ok(response.bytes()?.to_vec())
1884}
1885
1886/// Add lifecycle guidance only for targets that Hel destroys as a whole.
1887fn append_hel_target_environment(
1888    harness: mj_core::config::HarnessKind,
1889    destination: &Path,
1890    target: &targets::TargetLocator,
1891) -> Result<()> {
1892    let environment = match target {
1893        targets::TargetLocator::LocalPodman { .. }
1894        | targets::TargetLocator::LocalDocker { .. }
1895        | targets::TargetLocator::AppleContainer { .. }
1896        | targets::TargetLocator::SshPodman { .. }
1897        | targets::TargetLocator::SshDocker { .. } => MJ_CONTAINER_ENVIRONMENT.to_owned(),
1898        targets::TargetLocator::AwsEc2 { workspace, .. } => format!(
1899            "## Mjolnir disposable environment\n\nThis session runs on a disposable Mjolnir EC2 instance. When the session closes, Mjolnir checkpoints everything in project workspace directories under `$HOME/{workspace}`, including committed work, staged and unstaged changes, and untracked files. Mjolnir then terminates the instance.\n\nEverything outside `$HOME/{workspace}`, including installed packages, the rest of `$HOME`, and `/tmp`, is ephemeral and will be lost. Keep durable results in the workspace or push them to a remote.\n\nNew workspaces start on their own session branch from the default network fetch remote’s default branch. Local unpublished commits and uncommitted files are not copied. Use normal git push to publish the current branch to the configured network push destination. Closing saves a checkpoint; it does not publish commits or update the original local checkout. Resumed sessions restore their saved work.\n"
1900        ),
1901        targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. } => {
1902            return Ok(());
1903        }
1904    };
1905    let instructions = match harness {
1906        mj_core::config::HarnessKind::Codex => "AGENTS.md",
1907        mj_core::config::HarnessKind::Claude => "CLAUDE.md",
1908        mj_core::config::HarnessKind::Kimi => "AGENTS.md",
1909        mj_core::config::HarnessKind::Grok => "AGENTS.md",
1910        mj_core::config::HarnessKind::Muse => "AGENTS.md",
1911    };
1912    let path = destination.join(instructions);
1913    let separator = match std::fs::read_to_string(&path) {
1914        Ok(contents) if !contents.is_empty() && !contents.ends_with('\n') => "\n\n",
1915        Ok(contents) if !contents.is_empty() => "\n",
1916        Ok(_) => "",
1917        Err(error) if error.kind() == std::io::ErrorKind::NotFound => "",
1918        Err(error) => return Err(error.into()),
1919    };
1920    use std::io::Write;
1921
1922    let mut file = std::fs::OpenOptions::new()
1923        .create(true)
1924        .append(true)
1925        .open(&path)
1926        .with_context(|| format!("open staged harness instructions {}", path.display()))?;
1927    file.write_all(separator.as_bytes())?;
1928    file.write_all(environment.as_bytes())?;
1929    Ok(())
1930}
1931
1932fn copy_profile_entry(source: &Path, destination: &Path) -> Result<()> {
1933    copy_profile_entry_within(source, destination, &HashSet::new())
1934}
1935
1936/// Copy one profile entry, following symlinks so a profile home that links its
1937/// settings or instructions elsewhere still stages their contents. `entered`
1938/// holds the canonical paths of the directories already entered on this branch
1939/// of the recursion, which stops a symlinked directory cycle.
1940fn copy_profile_entry_within(
1941    source: &Path,
1942    destination: &Path,
1943    entered: &HashSet<PathBuf>,
1944) -> Result<()> {
1945    std::fs::symlink_metadata(source)
1946        .with_context(|| format!("read staged profile entry metadata {}", source.display()))?;
1947    let metadata = match std::fs::metadata(source) {
1948        Ok(metadata) => metadata,
1949        // The entry exists but its link target does not; staging the rest of
1950        // the profile is more useful than failing on a stale link.
1951        Err(error) if error.kind() == ErrorKind::NotFound => {
1952            tracing::warn!(
1953                source = %source.display(),
1954                "skipping staged profile entry whose symlink target is missing"
1955            );
1956            return Ok(());
1957        }
1958        Err(error) => {
1959            return Err(anyhow::Error::new(error).context(format!(
1960                "read staged profile entry metadata {}",
1961                source.display()
1962            )));
1963        }
1964    };
1965    if metadata.is_file() {
1966        if let Some(parent) = destination.parent() {
1967            std::fs::create_dir_all(parent)
1968                .with_context(|| format!("create staged profile directory {}", parent.display()))?;
1969        }
1970        std::fs::copy(source, destination).with_context(|| {
1971            format!(
1972                "copy staged profile file {} to {}",
1973                source.display(),
1974                destination.display()
1975            )
1976        })?;
1977        return Ok(());
1978    }
1979    if metadata.is_dir() {
1980        let canonical = std::fs::canonicalize(source)
1981            .with_context(|| format!("resolve staged profile directory {}", source.display()))?;
1982        if entered.contains(&canonical) {
1983            tracing::warn!(
1984                source = %source.display(),
1985                target = %canonical.display(),
1986                "skipping staged profile directory that links back into itself"
1987            );
1988            return Ok(());
1989        }
1990        let mut entered = entered.clone();
1991        entered.insert(canonical);
1992        std::fs::create_dir_all(destination).with_context(|| {
1993            format!("create staged profile directory {}", destination.display())
1994        })?;
1995        let entries = std::fs::read_dir(source)
1996            .with_context(|| format!("list staged profile directory {}", source.display()))?
1997            .collect::<std::io::Result<Vec<_>>>()
1998            .with_context(|| {
1999                format!(
2000                    "read staged profile directory entries in {}",
2001                    source.display()
2002                )
2003            })?;
2004        // Sibling entries in one directory are independent, so recurse in
2005        // parallel; this is the level most likely to hold many files (e.g. a
2006        // skills or plugins tree).
2007        entries.par_iter().try_for_each(|entry| {
2008            copy_profile_entry_within(
2009                &entry.path(),
2010                &destination.join(entry.file_name()),
2011                &entered,
2012            )
2013        })?;
2014        std::fs::set_permissions(destination, metadata.permissions()).with_context(|| {
2015            format!(
2016                "set permissions for staged profile directory {}",
2017                destination.display()
2018            )
2019        })?;
2020    }
2021    Ok(())
2022}
2023
2024// Container copies can create root-owned files even when exec defaults to a
2025// non-root image user. The worker directory was created by that user, so use
2026// its ownership for uploaded files before restricting their permissions.
2027pub(super) fn container_upload_ownership_args(
2028    container_id: &str,
2029    worker_root: &str,
2030    paths: &[&str],
2031) -> Vec<String> {
2032    let mut args = vec![
2033        "exec".into(),
2034        "--user".into(),
2035        "0".into(),
2036        container_id.into(),
2037        "sh".into(),
2038        "-c".into(),
2039        // GNU and BusyBox stat both support this numeric ownership format.
2040        r#"set -eu; owner=$(stat -c '%u:%g' -- "$1"); shift; chown -R "$owner" -- "$@""#.into(),
2041        "sh".into(),
2042        worker_root.into(),
2043    ];
2044    args.extend(paths.iter().map(|path| (*path).to_owned()));
2045    args
2046}
2047
2048#[allow(clippy::too_many_arguments)]
2049fn install_worker_files(
2050    executor: &impl CommandExecutor,
2051    locator: &targets::TargetLocator,
2052    session_id: &str,
2053    worker_root: &str,
2054    profile_home: &str,
2055    worker_binary: &Path,
2056    launch_config: &Path,
2057    ownership: &Path,
2058    profile_stage: &Path,
2059) -> Result<()> {
2060    match locator {
2061        targets::TargetLocator::LocalBare { .. } => {
2062            if profile_stage.is_dir() {
2063                std::fs::create_dir_all(profile_home).context("create isolated local profile")?;
2064                for entry in std::fs::read_dir(profile_stage)? {
2065                    let entry = entry?;
2066                    copy_profile_entry(
2067                        &entry.path(),
2068                        &Path::new(profile_home).join(entry.file_name()),
2069                    )?;
2070                }
2071            }
2072            for command in [
2073                CommandSpec::new("mkdir", ["-p", worker_root])
2074                    .purpose("create local bare worker directory"),
2075                CommandSpec::new(
2076                    "cp",
2077                    [
2078                        worker_binary.to_string_lossy().into_owned(),
2079                        format!("{worker_root}/hel"),
2080                    ],
2081                )
2082                .purpose("install local Mjolnir worker"),
2083                CommandSpec::new(
2084                    "cp",
2085                    [
2086                        launch_config.to_string_lossy().into_owned(),
2087                        format!("{worker_root}/launch.json"),
2088                    ],
2089                )
2090                .purpose("install local worker launch configuration"),
2091                CommandSpec::new(
2092                    "cp",
2093                    [
2094                        ownership.to_string_lossy().into_owned(),
2095                        format!("{worker_root}/ownership.json"),
2096                    ],
2097                )
2098                .purpose("install local worker ownership marker"),
2099                CommandSpec::new("chmod", ["700", &format!("{worker_root}/hel")])
2100                    .purpose("make local Mjolnir worker executable"),
2101            ] {
2102                execute_checked(executor, command)?;
2103            }
2104        }
2105        targets::TargetLocator::LocalPodman { container_id, .. }
2106        | targets::TargetLocator::LocalDocker { container_id }
2107        | targets::TargetLocator::AppleContainer { container_id } => {
2108            let engine = match locator {
2109                targets::TargetLocator::LocalPodman { .. } => "podman",
2110                targets::TargetLocator::LocalDocker { .. } => "docker",
2111                targets::TargetLocator::AppleContainer { .. } => "container",
2112                _ => unreachable!("matched local container target"),
2113            };
2114            for command in [
2115                CommandSpec::new(
2116                    engine,
2117                    [
2118                        "exec".into(),
2119                        container_id.clone(),
2120                        "mkdir".into(),
2121                        "-p".into(),
2122                        worker_root.into(),
2123                        profile_home.into(),
2124                    ],
2125                )
2126                .purpose("create target worker directories"),
2127                CommandSpec::new(
2128                    engine,
2129                    [
2130                        "cp".into(),
2131                        worker_binary.to_string_lossy().into_owned(),
2132                        format!("{container_id}:{worker_root}/hel"),
2133                    ],
2134                )
2135                .purpose("upload Mjolnir worker"),
2136                CommandSpec::new(
2137                    engine,
2138                    [
2139                        "cp".into(),
2140                        launch_config.to_string_lossy().into_owned(),
2141                        format!("{container_id}:{worker_root}/launch.json"),
2142                    ],
2143                )
2144                .purpose("upload worker launch configuration"),
2145                CommandSpec::new(
2146                    engine,
2147                    [
2148                        "cp".into(),
2149                        ownership.to_string_lossy().into_owned(),
2150                        format!("{container_id}:{worker_root}/ownership.json"),
2151                    ],
2152                )
2153                .purpose("upload worker ownership marker"),
2154                CommandSpec::new(
2155                    engine,
2156                    [
2157                        "cp".into(),
2158                        format!("{}/.", profile_stage.display()),
2159                        format!("{container_id}:{profile_home}"),
2160                    ],
2161                )
2162                .purpose("upload harness profile allowlist"),
2163                CommandSpec::new(
2164                    engine,
2165                    container_upload_ownership_args(
2166                        container_id,
2167                        worker_root,
2168                        &[
2169                            &format!("{worker_root}/hel"),
2170                            &format!("{worker_root}/launch.json"),
2171                            &format!("{worker_root}/ownership.json"),
2172                            profile_home,
2173                        ],
2174                    ),
2175                )
2176                .purpose("assign uploaded files to the worker user"),
2177                CommandSpec::new(
2178                    engine,
2179                    [
2180                        "exec".into(),
2181                        container_id.clone(),
2182                        "chmod".into(),
2183                        "700".into(),
2184                        format!("{worker_root}/hel"),
2185                    ],
2186                )
2187                .purpose("make Mjolnir worker executable"),
2188                CommandSpec::new(
2189                    engine,
2190                    [
2191                        "exec".into(),
2192                        container_id.clone(),
2193                        "chmod".into(),
2194                        "-R".into(),
2195                        "go-rwx".into(),
2196                        profile_home.into(),
2197                    ],
2198                )
2199                .purpose("restrict harness profile permissions"),
2200            ] {
2201                execute_checked(executor, command)?;
2202            }
2203        }
2204        targets::TargetLocator::AwsEc2 { ssh, .. }
2205        | targets::TargetLocator::SshBare { ssh, .. } => {
2206            install_worker_over_ssh(
2207                executor,
2208                ssh,
2209                worker_root,
2210                profile_home,
2211                worker_binary,
2212                launch_config,
2213                ownership,
2214                profile_stage,
2215            )?;
2216        }
2217        targets::TargetLocator::SshPodman {
2218            ssh, container_id, ..
2219        }
2220        | targets::TargetLocator::SshDocker { ssh, container_id } => {
2221            let engine = match locator {
2222                targets::TargetLocator::SshPodman { .. } => "podman",
2223                targets::TargetLocator::SshDocker { .. } => "docker",
2224                _ => unreachable!("matched remote container target"),
2225            };
2226            // The worker binary is 10-30 MB and identical across sessions, so
2227            // keep it in a content-addressed cache on the remote host and copy
2228            // it over the wire only once per unique binary.
2229            let digest = mj_core::worker_launch::worker_executable_digest(worker_binary)?;
2230            // Home-relative, not "~/": ssh_command_spec single-quotes every
2231            // argument, so a tilde would stay literal in the remote shell
2232            // while scp expands it, and the two sides would disagree. Both
2233            // ssh commands (cwd is the login home) and scp resolve a relative
2234            // path against the remote home.
2235            let cache_dir = format!(".cache/mjolnir/workers/{digest}");
2236            let cached_worker = format!("{cache_dir}/hel");
2237            let cached = matches!(
2238                executor.execute(
2239                    &ssh_command_spec(ssh, ["test", "-f", &cached_worker])
2240                        .purpose("probe cached remote Mjolnir worker"),
2241                ),
2242                Ok(output) if output.status == 0
2243            );
2244            if !cached {
2245                execute_checked(
2246                    executor,
2247                    ssh_command_spec(ssh, ["mkdir", "-p", &cache_dir])
2248                        .purpose("create remote worker cache"),
2249                )?;
2250                let partial = format!("{cache_dir}/hel.partial-{session_id}");
2251                execute_checked(
2252                    executor,
2253                    scp_command_spec(ssh, worker_binary, &partial, false)
2254                        .purpose("upload remote container worker binary"),
2255                )?;
2256                // Rename within the cache directory so the final path only
2257                // ever names a complete upload.
2258                execute_checked(
2259                    executor,
2260                    ssh_command_spec(ssh, ["mv", &partial, &cached_worker])
2261                        .purpose("publish cached remote Mjolnir worker"),
2262                )?;
2263            }
2264            let upload = format!(".cache/mjolnir/uploads/{session_id}");
2265            execute_checked(
2266                executor,
2267                ssh_command_spec(ssh, ["mkdir", "-p", &upload])
2268                    .purpose("create remote upload staging"),
2269            )?;
2270            for (source, name) in [
2271                (launch_config, "launch.json"),
2272                (ownership, "ownership.json"),
2273            ] {
2274                execute_checked(
2275                    executor,
2276                    scp_command_spec(ssh, source, &format!("{upload}/{name}"), false)
2277                        .purpose("upload remote container worker file"),
2278                )?;
2279            }
2280            execute_checked(
2281                executor,
2282                scp_command_spec(ssh, profile_stage, &format!("{upload}/profile"), true)
2283                    .purpose("upload remote container profile allowlist"),
2284            )?;
2285            let remote = [
2286                vec![
2287                    engine.into(),
2288                    "exec".into(),
2289                    container_id.clone(),
2290                    "mkdir".into(),
2291                    "-p".into(),
2292                    worker_root.into(),
2293                    profile_home.into(),
2294                ],
2295                vec![
2296                    engine.into(),
2297                    "cp".into(),
2298                    cached_worker.clone(),
2299                    format!("{container_id}:{worker_root}/hel"),
2300                ],
2301                vec![
2302                    engine.into(),
2303                    "cp".into(),
2304                    format!("{upload}/launch.json"),
2305                    format!("{container_id}:{worker_root}/launch.json"),
2306                ],
2307                vec![
2308                    engine.into(),
2309                    "cp".into(),
2310                    format!("{upload}/ownership.json"),
2311                    format!("{container_id}:{worker_root}/ownership.json"),
2312                ],
2313                vec![
2314                    engine.into(),
2315                    "cp".into(),
2316                    format!("{upload}/profile/."),
2317                    format!("{container_id}:{profile_home}"),
2318                ],
2319                std::iter::once(engine.to_owned())
2320                    .chain(container_upload_ownership_args(
2321                        container_id,
2322                        worker_root,
2323                        &[
2324                            &format!("{worker_root}/hel"),
2325                            &format!("{worker_root}/launch.json"),
2326                            &format!("{worker_root}/ownership.json"),
2327                            profile_home,
2328                        ],
2329                    ))
2330                    .collect(),
2331                vec![
2332                    engine.into(),
2333                    "exec".into(),
2334                    container_id.clone(),
2335                    "chmod".into(),
2336                    "700".into(),
2337                    format!("{worker_root}/hel"),
2338                ],
2339                vec![
2340                    engine.into(),
2341                    "exec".into(),
2342                    container_id.clone(),
2343                    "chmod".into(),
2344                    "-R".into(),
2345                    "go-rwx".into(),
2346                    profile_home.into(),
2347                ],
2348                vec!["rm".into(), "-rf".into(), "--".into(), upload.clone()],
2349            ];
2350            for args in remote {
2351                execute_checked(
2352                    executor,
2353                    ssh_command_spec(ssh, args).purpose("install remote container worker"),
2354                )?;
2355            }
2356        }
2357    }
2358    Ok(())
2359}
2360
2361#[allow(clippy::too_many_arguments)]
2362fn install_worker_over_ssh(
2363    executor: &impl CommandExecutor,
2364    ssh: &SshTarget,
2365    worker_root: &str,
2366    profile_home: &str,
2367    worker_binary: &Path,
2368    launch_config: &Path,
2369    ownership: &Path,
2370    profile_stage: &Path,
2371) -> Result<()> {
2372    execute_checked(
2373        executor,
2374        ssh_command_spec(ssh, ["mkdir", "-p", worker_root, profile_home])
2375            .purpose("create SSH worker directories"),
2376    )?;
2377    for (source, remote, recursive) in [
2378        (worker_binary, format!("{worker_root}/hel"), false),
2379        (launch_config, format!("{worker_root}/launch.json"), false),
2380        (ownership, format!("{worker_root}/ownership.json"), false),
2381    ] {
2382        execute_checked(
2383            executor,
2384            scp_command_spec(ssh, source, &remote, recursive).purpose("upload SSH worker file"),
2385        )?;
2386    }
2387    let incoming_profile = format!("{profile_home}.incoming");
2388    execute_checked(
2389        executor,
2390        scp_command_spec(ssh, profile_stage, &incoming_profile, true)
2391            .purpose("upload SSH harness profile allowlist"),
2392    )?;
2393    execute_checked(
2394        executor,
2395        ssh_command_spec(
2396            ssh,
2397            ["cp", "-R", &format!("{incoming_profile}/."), profile_home],
2398        )
2399        .purpose("install SSH harness profile allowlist"),
2400    )?;
2401    execute_checked(
2402        executor,
2403        ssh_command_spec(ssh, ["rm", "-rf", "--", &incoming_profile])
2404            .purpose("remove SSH profile staging"),
2405    )?;
2406    execute_checked(
2407        executor,
2408        ssh_command_spec(ssh, ["chmod", "700", &format!("{worker_root}/hel")])
2409            .purpose("make SSH worker executable"),
2410    )?;
2411    execute_checked(
2412        executor,
2413        ssh_command_spec(ssh, ["chmod", "-R", "go-rwx", profile_home])
2414            .purpose("restrict SSH harness profile permissions"),
2415    )?;
2416    Ok(())
2417}
2418
2419/// Replace `{worker_root}/hel` with the controller's current worker binary.
2420///
2421/// Checkpoint export starts that path as a new process. A live daemon already
2422/// has the previous inode mapped, so this does not restart it. Writing through
2423/// `hel.next` and renaming avoids `ETXTBSY` on a running image.
2424pub(super) fn replace_installed_worker_binary(
2425    executor: &impl CommandExecutor,
2426    locator: &targets::TargetLocator,
2427    session_id: &str,
2428    worker_binary: &Path,
2429) -> Result<()> {
2430    let plan = installed_worker_binary_replacement_plan(locator, session_id, worker_binary)?;
2431    for command in plan.commands {
2432        execute_checked(executor, command)?;
2433    }
2434    Ok(())
2435}
2436
2437pub(super) fn replace_installed_worker_launch_config(
2438    executor: &impl CommandExecutor,
2439    locator: &targets::TargetLocator,
2440    session_id: &str,
2441    launch: &WorkerLaunchConfig,
2442) -> Result<()> {
2443    let plan = worker_launch_refresh_plan(locator, session_id, launch)?;
2444    for command in plan.replace.commands {
2445        execute_checked(executor, command)?;
2446    }
2447    Ok(())
2448}
2449
2450/// Prepare the exact managed harness using the current worker binary. Remote
2451/// targets receive a separately staged copy; local bare targets run the binary
2452/// directly with a private launch config. The running worker is not stopped or
2453/// replaced, so any failure here leaves the quiet session attachable on its
2454/// previous build.
2455pub(super) fn prepare_managed_harness_for_upgrade(
2456    executor: &impl CommandExecutor,
2457    locator: &targets::TargetLocator,
2458    session_id: &str,
2459    worker_binary: &Path,
2460    launch: &WorkerLaunchConfig,
2461) -> Result<()> {
2462    if launch.harness_runtime != HarnessRuntimePolicy::Managed {
2463        return Ok(());
2464    }
2465    let worker_root = targets::worker_root(locator, session_id)?;
2466    let staging_root = format!("{worker_root}/harness-prepare");
2467    let staging_binary = format!("{staging_root}/hel");
2468    let staging_config = format!("{staging_root}/launch.json");
2469    let staging = tempfile::tempdir().context("create managed harness upgrade staging")?;
2470    let local_config = staging.path().join("launch.json");
2471    launch.write(&local_config)?;
2472
2473    // Local bare workers already share the controller's filesystem. Running
2474    // the current binary against a private launch config is enough to prepare
2475    // the cache, and leaves the live worker root completely untouched.
2476    if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
2477        execute_checked(
2478            executor,
2479            CommandSpec::new(
2480                worker_binary.to_string_lossy().into_owned(),
2481                [
2482                    "worker".to_owned(),
2483                    "prepare-harness".to_owned(),
2484                    "--config".to_owned(),
2485                    local_config.to_string_lossy().into_owned(),
2486                ],
2487            )
2488            .purpose("prepare exact managed harness"),
2489        )?;
2490        return Ok(());
2491    }
2492
2493    let ssh = match locator {
2494        targets::TargetLocator::AwsEc2 { ssh, .. }
2495        | targets::TargetLocator::SshBare { ssh, .. } => ssh,
2496        _ => bail!("managed harness policy requires a local bare, SSH-bare, or EC2 target"),
2497    };
2498    let result = (|| {
2499        execute_checked(
2500            executor,
2501            ssh_command_spec(ssh, ["rm", "-rf", "--", &staging_root])
2502                .purpose("clear managed harness preparation staging"),
2503        )?;
2504        execute_checked(
2505            executor,
2506            ssh_command_spec(ssh, ["mkdir", "-p", &staging_root])
2507                .purpose("create managed harness preparation staging"),
2508        )?;
2509        execute_checked(
2510            executor,
2511            scp_command_spec(ssh, worker_binary, &staging_binary, false)
2512                .purpose("stage current worker for managed harness preparation"),
2513        )?;
2514        execute_checked(
2515            executor,
2516            scp_command_spec(ssh, &local_config, &staging_config, false)
2517                .purpose("stage managed harness launch configuration"),
2518        )?;
2519        execute_checked(
2520            executor,
2521            ssh_command_spec(ssh, ["chmod", "700", &staging_binary])
2522                .purpose("make managed harness preparation worker executable"),
2523        )?;
2524        execute_checked(
2525            executor,
2526            ssh_command_spec(
2527                ssh,
2528                [
2529                    staging_binary.as_str(),
2530                    "worker",
2531                    "prepare-harness",
2532                    "--config",
2533                    staging_config.as_str(),
2534                ],
2535            )
2536            .purpose("prepare exact managed harness"),
2537        )?;
2538        Ok(())
2539    })();
2540    let cleanup = execute_checked(
2541        executor,
2542        ssh_command_spec(ssh, ["rm", "-rf", "--", &staging_root])
2543            .purpose("remove managed harness preparation staging"),
2544    );
2545    match (result, cleanup) {
2546        (Ok(()), Ok(_)) => Ok(()),
2547        (Ok(()), Err(error)) => Err(error).context("clean managed harness preparation staging"),
2548        (Err(error), Ok(_)) => Err(error),
2549        (Err(error), Err(cleanup)) => {
2550            tracing::warn!(%cleanup, path = %staging_root, "managed harness preparation staging cleanup failed");
2551            Err(error)
2552        }
2553    }
2554}
2555
2556fn prepare_installed_managed_harness(
2557    executor: &impl CommandExecutor,
2558    locator: &targets::TargetLocator,
2559    worker_root: &str,
2560    launch: &WorkerLaunchConfig,
2561) -> Result<()> {
2562    if launch.harness_runtime != HarnessRuntimePolicy::Managed {
2563        return Ok(());
2564    }
2565    let worker_binary = format!("{worker_root}/hel");
2566    let launch_config = format!("{worker_root}/launch.json");
2567    let command = match locator {
2568        targets::TargetLocator::LocalBare { .. } => CommandSpec::new(
2569            worker_binary.clone(),
2570            [
2571                "worker",
2572                "prepare-harness",
2573                "--config",
2574                launch_config.as_str(),
2575            ],
2576        ),
2577        targets::TargetLocator::AwsEc2 { ssh, .. }
2578        | targets::TargetLocator::SshBare { ssh, .. } => ssh_command_spec(
2579            ssh,
2580            [
2581                worker_binary.as_str(),
2582                "worker",
2583                "prepare-harness",
2584                "--config",
2585                launch_config.as_str(),
2586            ],
2587        ),
2588        _ => bail!("managed harness policy requires a local bare, SSH-bare, or EC2 target"),
2589    };
2590    execute_checked(
2591        executor,
2592        command.purpose("prepare exact managed harness before worker startup"),
2593    )?;
2594    Ok(())
2595}
2596
2597fn installed_worker_binary_replacement_plan(
2598    locator: &targets::TargetLocator,
2599    session_id: &str,
2600    worker_binary: &Path,
2601) -> Result<CommandPlan> {
2602    let worker_root = targets::worker_root(locator, session_id)?;
2603    let installed = format!("{worker_root}/hel");
2604    let staged = format!("{worker_root}/hel.next");
2605    let commands = match locator {
2606        targets::TargetLocator::LocalBare { .. } => vec![
2607            CommandSpec::new(
2608                "cp",
2609                [worker_binary.to_string_lossy().into_owned(), staged.clone()],
2610            )
2611            .purpose("stage replacement Mjolnir worker"),
2612            CommandSpec::new("mv", ["-f", &staged, &installed])
2613                .purpose("replace installed Mjolnir worker"),
2614            CommandSpec::new("chmod", ["700", &installed])
2615                .purpose("make replaced Mjolnir worker executable"),
2616        ],
2617        targets::TargetLocator::LocalPodman { container_id, .. }
2618        | targets::TargetLocator::LocalDocker { container_id }
2619        | targets::TargetLocator::AppleContainer { container_id } => {
2620            let engine = match locator {
2621                targets::TargetLocator::LocalPodman { .. } => "podman",
2622                targets::TargetLocator::LocalDocker { .. } => "docker",
2623                targets::TargetLocator::AppleContainer { .. } => "container",
2624                _ => unreachable!("matched local container target"),
2625            };
2626            vec![
2627                CommandSpec::new(
2628                    engine,
2629                    [
2630                        "cp".into(),
2631                        worker_binary.to_string_lossy().into_owned(),
2632                        format!("{container_id}:{staged}"),
2633                    ],
2634                )
2635                .purpose("stage replacement Mjolnir worker"),
2636                CommandSpec::new(
2637                    engine,
2638                    container_upload_ownership_args(container_id, &worker_root, &[&staged]),
2639                )
2640                .purpose("assign replacement worker to the worker user"),
2641                CommandSpec::new(
2642                    engine,
2643                    [
2644                        "exec".into(),
2645                        container_id.clone(),
2646                        "mv".into(),
2647                        "-f".into(),
2648                        staged,
2649                        installed.clone(),
2650                    ],
2651                )
2652                .purpose("replace installed Mjolnir worker"),
2653                CommandSpec::new(
2654                    engine,
2655                    [
2656                        "exec".into(),
2657                        container_id.clone(),
2658                        "chmod".into(),
2659                        "700".into(),
2660                        installed,
2661                    ],
2662                )
2663                .purpose("make replaced Mjolnir worker executable"),
2664            ]
2665        }
2666        targets::TargetLocator::AwsEc2 { ssh, .. }
2667        | targets::TargetLocator::SshBare { ssh, .. } => vec![
2668            scp_command_spec(ssh, worker_binary, &staged, false)
2669                .purpose("stage replacement Mjolnir worker"),
2670            ssh_command_spec(ssh, ["mv", "-f", "--", &staged, &installed])
2671                .purpose("replace installed Mjolnir worker"),
2672            ssh_command_spec(ssh, ["chmod", "700", &installed])
2673                .purpose("make replaced Mjolnir worker executable"),
2674        ],
2675        targets::TargetLocator::SshPodman {
2676            ssh, container_id, ..
2677        }
2678        | targets::TargetLocator::SshDocker { ssh, container_id } => {
2679            let engine = match locator {
2680                targets::TargetLocator::SshPodman { .. } => "podman",
2681                targets::TargetLocator::SshDocker { .. } => "docker",
2682                _ => unreachable!("matched remote container target"),
2683            };
2684            let upload = format!(".cache/mjolnir/uploads/{session_id}-hel.next");
2685            vec![
2686                ssh_command_spec(ssh, ["mkdir", "-p", ".cache/mjolnir/uploads"])
2687                    .purpose("create remote replacement worker staging"),
2688                scp_command_spec(ssh, worker_binary, &upload, false)
2689                    .purpose("stage replacement Mjolnir worker"),
2690                ssh_command_spec(
2691                    ssh,
2692                    [engine, "cp", &upload, &format!("{container_id}:{staged}")],
2693                )
2694                .purpose("stage replacement Mjolnir worker"),
2695                ssh_command_spec(
2696                    ssh,
2697                    std::iter::once(engine.to_owned()).chain(container_upload_ownership_args(
2698                        container_id,
2699                        &worker_root,
2700                        &[&staged],
2701                    )),
2702                )
2703                .purpose("assign replacement worker to the worker user"),
2704                ssh_command_spec(
2705                    ssh,
2706                    [
2707                        engine,
2708                        "exec",
2709                        container_id,
2710                        "mv",
2711                        "-f",
2712                        "--",
2713                        &staged,
2714                        &installed,
2715                    ],
2716                )
2717                .purpose("replace installed Mjolnir worker"),
2718                ssh_command_spec(
2719                    ssh,
2720                    [engine, "exec", container_id, "chmod", "700", &installed],
2721                )
2722                .purpose("make replaced Mjolnir worker executable"),
2723                ssh_command_spec(ssh, ["rm", "-f", "--", &upload])
2724                    .purpose("remove remote replacement worker staging"),
2725            ]
2726        }
2727    };
2728    Ok(CommandPlan {
2729        description: format!("replace stale Mjolnir worker for session {session_id}"),
2730        commands,
2731    })
2732}
2733
2734fn installed_file_digest_command(
2735    locator: &targets::TargetLocator,
2736    path: &str,
2737    purpose: &str,
2738) -> CommandSpec {
2739    match locator {
2740        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sha256sum", [path]),
2741        targets::TargetLocator::LocalPodman { container_id, .. } => {
2742            CommandSpec::new("podman", ["exec", container_id, "sha256sum", path])
2743        }
2744        targets::TargetLocator::LocalDocker { container_id } => {
2745            CommandSpec::new("docker", ["exec", container_id, "sha256sum", path])
2746        }
2747        targets::TargetLocator::AppleContainer { container_id } => {
2748            CommandSpec::new("container", ["exec", container_id, "sha256sum", path])
2749        }
2750        targets::TargetLocator::AwsEc2 { ssh, .. }
2751        | targets::TargetLocator::SshBare { ssh, .. } => ssh_command_spec(ssh, ["sha256sum", path]),
2752        targets::TargetLocator::SshPodman {
2753            ssh, container_id, ..
2754        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sha256sum", path]),
2755        targets::TargetLocator::SshDocker { ssh, container_id } => {
2756            ssh_command_spec(ssh, ["docker", "exec", container_id, "sha256sum", path])
2757        }
2758    }
2759    .purpose(purpose)
2760}
2761
2762fn worker_launch_refresh_plan(
2763    locator: &targets::TargetLocator,
2764    session_id: &str,
2765    launch: &WorkerLaunchConfig,
2766) -> Result<WorkerLaunchRefreshPlan> {
2767    let worker_root = targets::worker_root(locator, session_id)?;
2768    let installed = format!("{worker_root}/launch.json");
2769    let staged = format!("{installed}.next");
2770    let staged_arg = targets::join_remote_command(std::slice::from_ref(&staged));
2771    let installed_arg = targets::join_remote_command(std::slice::from_ref(&installed));
2772    let script = format!("umask 077; cat > {staged_arg} && mv -f -- {staged_arg} {installed_arg}");
2773    let body = serde_json::to_vec_pretty(launch).context("serialize worker launch config")?;
2774    let expected_sha256 = format!("{:x}", Sha256::digest(&body));
2775    let replace = match locator {
2776        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2777        targets::TargetLocator::LocalPodman { container_id, .. } => {
2778            CommandSpec::new("podman", ["exec", "-i", container_id, "sh", "-c", &script])
2779        }
2780        targets::TargetLocator::LocalDocker { container_id } => {
2781            CommandSpec::new("docker", ["exec", "-i", container_id, "sh", "-c", &script])
2782        }
2783        targets::TargetLocator::AppleContainer { container_id } => CommandSpec::new(
2784            "container",
2785            ["exec", "-i", container_id, "sh", "-c", &script],
2786        ),
2787        targets::TargetLocator::AwsEc2 { ssh, .. }
2788        | targets::TargetLocator::SshBare { ssh, .. } => {
2789            ssh_command_spec(ssh, ["sh", "-c", &script])
2790        }
2791        targets::TargetLocator::SshPodman {
2792            ssh, container_id, ..
2793        } => ssh_command_spec(
2794            ssh,
2795            ["podman", "exec", "-i", container_id, "sh", "-c", &script],
2796        ),
2797        targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
2798            ssh,
2799            ["docker", "exec", "-i", container_id, "sh", "-c", &script],
2800        ),
2801    }
2802    .purpose("replace stale Mjolnir worker launch config")
2803    .with_sensitive_stdin(body);
2804    Ok(WorkerLaunchRefreshPlan {
2805        expected_sha256,
2806        installed_digest: installed_file_digest_command(
2807            locator,
2808            &installed,
2809            "identify installed Mjolnir worker launch config",
2810        ),
2811        replace: CommandPlan {
2812            description: format!("replace stale Mjolnir launch config for session {session_id}"),
2813            commands: vec![replace],
2814        },
2815    })
2816}
2817
2818/// Prepare a local refresh without hashing the controller binary. Digesting
2819/// happens only after recovery has proved that the worker needs a restart.
2820fn worker_binary_refresh_plan(
2821    locator: &targets::TargetLocator,
2822    session_id: &str,
2823) -> Result<Option<WorkerBinaryRefresh>> {
2824    let worker_root = targets::worker_root(locator, session_id)?;
2825    let installed = format!("{worker_root}/hel");
2826    // Remote targets defer source selection to the recovery task: choosing the
2827    // binary needs the target's architecture, and probing it (plus hashing the
2828    // remote binary) is blocking ssh work that must not run on this UI/event
2829    // path. Building the refresh here stays cheap.
2830    if matches!(
2831        locator,
2832        targets::TargetLocator::AwsEc2 { .. }
2833            | targets::TargetLocator::SshBare { .. }
2834            | targets::TargetLocator::SshPodman { .. }
2835            | targets::TargetLocator::SshDocker { .. }
2836    ) {
2837        return Ok(Some(WorkerBinaryRefresh::Remote(
2838            RemoteWorkerBinaryRefresh {
2839                locator: locator.clone(),
2840                session_id: session_id.to_owned(),
2841                installed_digest: installed_file_digest_command(
2842                    locator,
2843                    &installed,
2844                    "identify installed Mjolnir worker binary",
2845                ),
2846            },
2847        )));
2848    }
2849    // Local: resolve the source now. Resolving a deleted running executable
2850    // materializes /proc/self/exe and can copy hundreds of megabytes; target
2851    // lists are assembled on UI/event loops, so leave refresh disabled until
2852    // the next controller start rather than doing that work here.
2853    if PINNED_WORKER_BINARY_SOURCES.get().is_none()
2854        && !std::env::current_exe().is_ok_and(|path| path.is_file())
2855    {
2856        return Ok(None);
2857    }
2858    let requirement = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
2859        WorkerBinaryRequirement::LocalHost
2860    } else {
2861        WorkerBinaryRequirement::PortableLinux
2862    };
2863    let source = match worker_binary_for_arch(std::env::consts::ARCH, requirement) {
2864        Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2865        Ok(WorkerBinaryAvailability::Remote { .. }) | Err(_) => return Ok(None),
2866    };
2867    Ok(Some(WorkerBinaryRefresh::Prepared(
2868        WorkerBinaryRefreshPlan {
2869            replace: installed_worker_binary_replacement_plan(locator, session_id, &source)?,
2870            source,
2871            installed_digest: installed_file_digest_command(
2872                locator,
2873                &installed,
2874                "identify installed Mjolnir worker binary",
2875            ),
2876        },
2877    )))
2878}
2879
2880/// Refresh a remote worker binary during recovery: pick the worker binary for
2881/// the target's own architecture, and copy it over the installed one only when
2882/// their digests differ. This runs inside the recovery task, where blocking
2883/// ssh work is allowed; it must never be called from a UI/event loop.
2884///
2885/// The digest gate is what stops a redeploy loop: once the right binary is
2886/// installed, its digest matches the source and nothing is copied again, even
2887/// though recovery may still restart the worker.
2888pub(crate) fn refresh_remote_worker_binary_if_stale(
2889    executor: &impl CommandExecutor,
2890    refresh: &RemoteWorkerBinaryRefresh,
2891) -> Result<()> {
2892    let source = worker_binary_for(&refresh.locator, executor)
2893        .context("resolve the worker binary for the recovering target")?;
2894    replace_remote_worker_binary_if_stale(
2895        executor,
2896        &refresh.locator,
2897        &refresh.session_id,
2898        &refresh.installed_digest,
2899        &source,
2900    )
2901    .map(|_| ())
2902}
2903
2904/// Copy `source` over the installed remote worker only when the installed
2905/// digest differs from `source`'s. Returns whether a copy ran. Split from the
2906/// resolver above so the digest gate is testable without resolving a real
2907/// worker binary for a target architecture.
2908fn replace_remote_worker_binary_if_stale(
2909    executor: &impl CommandExecutor,
2910    locator: &targets::TargetLocator,
2911    session_id: &str,
2912    installed_digest: &CommandSpec,
2913    source: &Path,
2914) -> Result<bool> {
2915    let expected = mj_core::worker_launch::worker_executable_digest(source)?;
2916    let installed = executor
2917        .execute(installed_digest)
2918        .context("read the installed remote worker digest")?;
2919    let matches = installed.status == 0
2920        && String::from_utf8_lossy(&installed.stdout)
2921            .split_whitespace()
2922            .next()
2923            .is_some_and(|digest| digest.eq_ignore_ascii_case(&expected));
2924    if matches {
2925        return Ok(false);
2926    }
2927    installed_worker_binary_replacement_plan(locator, session_id, source)?
2928        .execute(executor)
2929        .context("replace stale remote relay worker binary")?;
2930    Ok(true)
2931}
2932
2933/// Stop the detached worker daemon at `worker_root` without deleting its files.
2934///
2935/// The script signals the worker's process group so a wedged ACP child dies
2936/// with it. Checkpoint then restarts the daemon against the same relay root.
2937pub(super) fn stop_worker(
2938    executor: &impl CommandExecutor,
2939    locator: &targets::TargetLocator,
2940    worker_root: &str,
2941) -> Result<()> {
2942    execute_checked(executor, stop_worker_command(locator, worker_root))?;
2943    Ok(())
2944}
2945
2946/// Restore a stopped Podman target before signaling its worker. Checkpoint
2947/// recovery uses this instead of assuming every persisted target is running.
2948pub(super) fn stop_worker_after_target_recovery(
2949    executor: &impl CommandExecutor,
2950    locator: &targets::TargetLocator,
2951    session_id: &str,
2952    worker_root: &str,
2953) -> Result<()> {
2954    let target = targets::target_recovery_plan(locator, session_id)?;
2955    targets::ensure_recovery_target_running(executor, target.as_ref())
2956        .context("restore Mjolnir worker target")?;
2957    stop_worker(executor, locator, worker_root)
2958}
2959
2960fn stop_worker_command(locator: &targets::TargetLocator, worker_root: &str) -> CommandSpec {
2961    let script = targets::stop_worker_daemon_script(worker_root);
2962    match locator {
2963        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2964        targets::TargetLocator::LocalPodman { container_id, .. } => {
2965            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
2966        }
2967        targets::TargetLocator::LocalDocker { container_id } => {
2968            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
2969        }
2970        targets::TargetLocator::AppleContainer { container_id } => {
2971            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
2972        }
2973        targets::TargetLocator::AwsEc2 { ssh, .. }
2974        | targets::TargetLocator::SshBare { ssh, .. } => {
2975            ssh_command_spec(ssh, ["sh", "-c", &script])
2976        }
2977        targets::TargetLocator::SshPodman {
2978            ssh, container_id, ..
2979        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
2980        targets::TargetLocator::SshDocker { ssh, container_id } => {
2981            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
2982        }
2983    }
2984    .purpose("stop Mjolnir worker daemon")
2985}
2986
2987fn worker_liveness_command(locator: &targets::TargetLocator, worker_root: &str) -> CommandSpec {
2988    let script = targets::worker_daemon_liveness_script(worker_root);
2989    match locator {
2990        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2991        targets::TargetLocator::LocalPodman { container_id, .. } => {
2992            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
2993        }
2994        targets::TargetLocator::LocalDocker { container_id } => {
2995            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
2996        }
2997        targets::TargetLocator::AppleContainer { container_id } => {
2998            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
2999        }
3000        targets::TargetLocator::AwsEc2 { ssh, .. }
3001        | targets::TargetLocator::SshBare { ssh, .. } => {
3002            ssh_command_spec(ssh, ["sh", "-c", &script])
3003        }
3004        targets::TargetLocator::SshPodman {
3005            ssh, container_id, ..
3006        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
3007        targets::TargetLocator::SshDocker { ssh, container_id } => {
3008            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
3009        }
3010    }
3011    .purpose("probe Mjolnir worker daemon liveness")
3012}
3013
3014pub(super) fn start_worker(
3015    executor: &impl CommandExecutor,
3016    locator: &targets::TargetLocator,
3017    worker_root: &str,
3018) -> Result<()> {
3019    execute_checked(executor, start_worker_command(locator, worker_root))?;
3020    Ok(())
3021}
3022
3023fn start_worker_command(locator: &targets::TargetLocator, worker_root: &str) -> CommandSpec {
3024    let binary = format!("{worker_root}/hel");
3025    let config = format!("{worker_root}/launch.json");
3026    // These files describe the worker's previous life. Clear them as part of
3027    // the launch, before the new daemon can be probed: a stale exit record
3028    // aborts startup, while a stale socket makes a recovering daemon look
3029    // ready and invites the reconnect actor to kill it as unresponsive.
3030    let clear_stale_runtime = format!(
3031        "rm -f {} {}; ",
3032        targets::join_remote_command(&[format!("{worker_root}/worker-exit.json")]),
3033        targets::join_remote_command(&[format!("{worker_root}/control.sock")]),
3034    );
3035    let detached_script = format!(
3036        "{clear_stale_runtime}nohup {} >{} 2>&1 </dev/null &",
3037        targets::join_remote_command(&[
3038            binary.clone(),
3039            "worker".into(),
3040            "run".into(),
3041            "--root".into(),
3042            worker_root.into(),
3043            "--config".into(),
3044            config.clone(),
3045        ]),
3046        targets::join_remote_command(&[format!("{worker_root}/worker.log")]),
3047    );
3048    // Redirect daemon output to worker.log in every launch mode; an
3049    // unexplained dead worker is undebuggable without it.
3050    let exec_script = format!(
3051        "{clear_stale_runtime}exec {} >{} 2>&1",
3052        targets::join_remote_command(&[
3053            binary.clone(),
3054            "worker".into(),
3055            "run".into(),
3056            "--root".into(),
3057            worker_root.into(),
3058            "--config".into(),
3059            config.clone(),
3060        ]),
3061        targets::join_remote_command(&[format!("{worker_root}/worker.log")]),
3062    );
3063    match locator {
3064        targets::TargetLocator::LocalBare { .. } => {
3065            CommandSpec::new("sh", ["-c", &detached_script])
3066        }
3067        targets::TargetLocator::LocalPodman { container_id, .. } => CommandSpec::new(
3068            "podman",
3069            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
3070        ),
3071        targets::TargetLocator::LocalDocker { container_id } => CommandSpec::new(
3072            "docker",
3073            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
3074        ),
3075        targets::TargetLocator::AppleContainer { container_id } => CommandSpec::new(
3076            "container",
3077            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
3078        ),
3079        targets::TargetLocator::AwsEc2 { ssh, .. }
3080        | targets::TargetLocator::SshBare { ssh, .. } => {
3081            ssh_command_spec(ssh, ["sh", "-c", &detached_script])
3082        }
3083        targets::TargetLocator::SshPodman {
3084            ssh, container_id, ..
3085        } => ssh_command_spec(
3086            ssh,
3087            [
3088                "podman",
3089                "exec",
3090                "--detach",
3091                container_id,
3092                "sh",
3093                "-c",
3094                &exec_script,
3095            ],
3096        ),
3097        targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
3098            ssh,
3099            [
3100                "docker",
3101                "exec",
3102                "--detach",
3103                container_id,
3104                "sh",
3105                "-c",
3106                &exec_script,
3107            ],
3108        ),
3109    }
3110    .purpose("start detached Mjolnir worker")
3111    // Everything before this moves data into the target and reports as Sync.
3112    // Start begins here, with the daemon launch.
3113    .stage(ProvisionStage::Starting)
3114}
3115
3116/// Enrich an opaque handshake failure by running the installed worker binary
3117/// directly in the target. This surfaces loader errors (for example a
3118/// glibc-linked worker inside an older-glibc container) that a detached start
3119/// swallows.
3120pub(super) fn worker_probe_diagnosis(
3121    executor: &impl CommandExecutor,
3122    locator: &targets::TargetLocator,
3123    worker_root: &str,
3124    error: anyhow::Error,
3125) -> anyhow::Error {
3126    let error = match worker_binary_probe_failure(executor, locator, worker_root) {
3127        Some(failure) => error.context(failure),
3128        None => error,
3129    };
3130    match worker_last_words(executor, locator, worker_root) {
3131        Some(last_words) => error.context(last_words),
3132        None => error,
3133    }
3134}
3135
3136fn worker_binary_probe_failure(
3137    executor: &impl CommandExecutor,
3138    locator: &targets::TargetLocator,
3139    worker_root: &str,
3140) -> Option<String> {
3141    let binary = format!("{worker_root}/hel");
3142    let command = match locator {
3143        targets::TargetLocator::LocalBare { .. } => CommandSpec::new(binary.clone(), ["--version"]),
3144        targets::TargetLocator::LocalPodman { container_id, .. } => {
3145            CommandSpec::new("podman", ["exec", container_id, &binary, "--version"])
3146        }
3147        targets::TargetLocator::LocalDocker { container_id } => {
3148            CommandSpec::new("docker", ["exec", container_id, &binary, "--version"])
3149        }
3150        targets::TargetLocator::AppleContainer { container_id } => {
3151            CommandSpec::new("container", ["exec", container_id, &binary, "--version"])
3152        }
3153        targets::TargetLocator::AwsEc2 { ssh, .. }
3154        | targets::TargetLocator::SshBare { ssh, .. } => {
3155            ssh_command_spec(ssh, [binary.as_str(), "--version"])
3156        }
3157        targets::TargetLocator::SshPodman {
3158            ssh, container_id, ..
3159        } => ssh_command_spec(
3160            ssh,
3161            ["podman", "exec", container_id, binary.as_str(), "--version"],
3162        ),
3163        targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
3164            ssh,
3165            ["docker", "exec", container_id, binary.as_str(), "--version"],
3166        ),
3167    }
3168    .purpose("probe installed worker binary");
3169    match executor.execute(&command) {
3170        Ok(output) if output.status == 0 => None,
3171        Ok(output) => {
3172            let stderr = String::from_utf8_lossy(&output.stderr);
3173            let stdout = String::from_utf8_lossy(&output.stdout);
3174            let detail = if !stderr.trim().is_empty() {
3175                stderr.trim()
3176            } else if !stdout.trim().is_empty() {
3177                stdout.trim()
3178            } else {
3179                "the process exited unsuccessfully without output"
3180            };
3181            Some(format!(
3182                "worker binary {binary} fails to run in the target: {detail}; \
3183                 if this is a loader/glibc error, provide a musl worker \
3184                 (cargo build --release --target <arch>-unknown-linux-musl \
3185                  -p brokk-mj-worker --bin mj-worker, \
3186                 or set MJ_WORKER_BINARY/MJ_WORKER_DIR)"
3187            ))
3188        }
3189        Err(probe_error) => Some(format!("worker probe failed: {probe_error:#}")),
3190    }
3191}
3192
3193/// Fetch the dead worker's structured exit record and log tail from the
3194/// target, so unreachable-worker errors carry the root cause.
3195pub(super) fn worker_last_words(
3196    executor: &impl CommandExecutor,
3197    locator: &targets::TargetLocator,
3198    worker_root: &str,
3199) -> Option<String> {
3200    let script = format!(
3201        "if [ -f {root}/worker-exit.json ]; then echo '{marker}'; cat {root}/worker-exit.json; fi; if [ -f {root}/worker.log ]; then echo '--- worker.log (tail) ---'; tail -n 20 {root}/worker.log; fi",
3202        root = targets::posix_quote(worker_root),
3203        marker = WORKER_EXIT_RECORD_MARKER
3204    );
3205    let command = match locator {
3206        targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
3207        targets::TargetLocator::LocalPodman { container_id, .. } => {
3208            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
3209        }
3210        targets::TargetLocator::LocalDocker { container_id } => {
3211            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
3212        }
3213        targets::TargetLocator::AppleContainer { container_id } => {
3214            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
3215        }
3216        targets::TargetLocator::AwsEc2 { ssh, .. }
3217        | targets::TargetLocator::SshBare { ssh, .. } => {
3218            ssh_command_spec(ssh, ["sh", "-c", &script])
3219        }
3220        targets::TargetLocator::SshPodman {
3221            ssh, container_id, ..
3222        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
3223        targets::TargetLocator::SshDocker { ssh, container_id } => {
3224            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
3225        }
3226    }
3227    .purpose("collect worker last words");
3228    let output = match executor.execute(&command) {
3229        Ok(output) => output,
3230        Err(error) => {
3231            tracing::debug!(
3232                worker_root,
3233                %error,
3234                "could not collect worker diagnostics"
3235            );
3236            return None;
3237        }
3238    };
3239    if output.status != 0 {
3240        tracing::debug!(
3241            worker_root,
3242            status = output.status,
3243            "worker diagnostic probe returned a failure"
3244        );
3245        return None;
3246    }
3247    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
3248    (!text.is_empty()).then(|| format!("worker diagnostics:\n{text}"))
3249}
3250
3251#[cfg(test)]
3252mod tests {
3253    use super::*;
3254
3255    use anyhow::Result;
3256
3257    use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
3258    use mj_core::config::ExecutionPolicy;
3259
3260    use sha2::{Digest, Sha256};
3261    use std::cell::RefCell;
3262    use std::collections::BTreeMap;
3263
3264    use std::path::{Path, PathBuf};
3265
3266    /// The session's stored choice decides, with the global setting as the
3267    /// fallback, and a child never gets the tools whatever either says.
3268    #[test]
3269    fn the_session_choice_decides_whether_mjolnir_replaces_native_delegation() {
3270        let claude = |choice| {
3271            let mut session = crate::controller::test_support::checkpoint_test_session("s-1");
3272            session.harness_kind = HarnessKind::Claude;
3273            session.mjolnir_subagents = choice;
3274            session
3275        };
3276
3277        assert!(!subagent_tools_enabled(&claude(Some(false)), true, false));
3278        assert!(subagent_tools_enabled(&claude(Some(true)), false, false));
3279        assert!(subagent_tools_enabled(&claude(None), true, false));
3280        assert!(!subagent_tools_enabled(&claude(None), false, false));
3281        assert!(!subagent_tools_enabled(&claude(Some(true)), true, true));
3282
3283        let mut grok = claude(Some(true));
3284        grok.harness_kind = HarnessKind::Grok;
3285        assert!(!subagent_tools_enabled(&grok, true, false));
3286
3287        let mut codex = claude(None);
3288        codex.harness_kind = HarnessKind::Codex;
3289        assert!(subagent_tools_enabled(&codex, true, false));
3290        codex.mjolnir_subagents = Some(false);
3291        assert!(!subagent_tools_enabled(&codex, true, false));
3292    }
3293
3294    #[cfg(unix)]
3295    #[test]
3296    fn node_preflight_checks_missing_old_and_supported_tools_on_profile_path() {
3297        use std::os::unix::fs::PermissionsExt;
3298        let directory = tempfile::tempdir().unwrap();
3299        let profile = HarnessProfile {
3300            enabled: true,
3301            kind: HarnessKind::Codex,
3302            home: directory.path().into(),
3303            environment: std::collections::BTreeMap::from([(
3304                "PATH".into(),
3305                directory.path().to_string_lossy().into_owned(),
3306            )]),
3307            context_window_bytes: None,
3308            guardian_review_model: None,
3309        };
3310        let check = || {
3311            preflight_harness(
3312                &mj_core::config::TargetTemplate::LocalBare,
3313                &profile,
3314                &ProcessExecutor,
3315            )
3316        };
3317        let write_tool = |name: &str, body: &str| {
3318            let path = directory.path().join(name);
3319            std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
3320            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
3321        };
3322        assert!(format!("{:#}", check().unwrap_err()).contains("Node.js is missing"));
3323        write_tool("node", "exit 1");
3324        assert!(format!("{:#}", check().unwrap_err()).contains("Node.js 22 or newer is required"));
3325        write_tool("node", "exit 0");
3326        assert!(format!("{:#}", check().unwrap_err()).contains("npm is missing or unusable"));
3327        write_tool("npm", "exit 0");
3328        check().unwrap();
3329    }
3330
3331    #[test]
3332    fn a_stored_setup_token_reaches_only_claude_workers_that_do_not_set_their_own() {
3333        use mj_core::config::HarnessKind;
3334        use mj_core::credentials::{CLAUDE_OAUTH_TOKEN_ENV, write_claude_oauth_token};
3335
3336        let directory = tempfile::tempdir().unwrap();
3337        let token_path = directory.path().join("profiles/claude/claude-oauth-token");
3338        let missing = directory.path().join("profiles/absent/claude-oauth-token");
3339        write_claude_oauth_token(&token_path, b"sk-ant-oat01-stored").unwrap();
3340
3341        let mut claude = BTreeMap::new();
3342        apply_claude_setup_token(&mut claude, HarnessKind::Claude, &token_path);
3343        assert_eq!(
3344            claude.get(CLAUDE_OAUTH_TOKEN_ENV).map(String::as_str),
3345            Some("sk-ant-oat01-stored")
3346        );
3347
3348        // Every other harness ignores the variable, so it must not appear.
3349        for kind in HarnessKind::ALL
3350            .into_iter()
3351            .filter(|kind| *kind != HarnessKind::Claude)
3352        {
3353            let mut environment = BTreeMap::new();
3354            apply_claude_setup_token(&mut environment, kind, &token_path);
3355            assert!(environment.is_empty(), "{kind:?} must not read the token");
3356        }
3357
3358        // A profile that sets the variable itself stays authoritative.
3359        let mut overridden = BTreeMap::from([(
3360            CLAUDE_OAUTH_TOKEN_ENV.to_owned(),
3361            "profile-token".to_owned(),
3362        )]);
3363        apply_claude_setup_token(&mut overridden, HarnessKind::Claude, &token_path);
3364        assert_eq!(
3365            overridden.get(CLAUDE_OAUTH_TOKEN_ENV).map(String::as_str),
3366            Some("profile-token")
3367        );
3368
3369        // A profile with no stored token launches exactly as before.
3370        let mut without = BTreeMap::new();
3371        apply_claude_setup_token(&mut without, HarnessKind::Claude, &missing);
3372        assert!(without.is_empty());
3373    }
3374
3375    #[test]
3376    fn packaged_worker_names_match_release_archives() {
3377        let directory = Path::new("/opt/hel/bin");
3378        assert_eq!(
3379            packaged_worker_binary_path(directory, "x86_64-unknown-linux-musl"),
3380            directory.join("mj-worker-x86_64-unknown-linux-musl")
3381        );
3382        assert_eq!(
3383            packaged_worker_binary_path(directory, "aarch64-unknown-linux-musl"),
3384            directory.join("mj-worker-aarch64-unknown-linux-musl")
3385        );
3386    }
3387
3388    #[test]
3389    fn pinned_snapshot_keeps_native_and_portable_sources_stable() {
3390        let directory = tempfile::tempdir().unwrap();
3391        let native = directory.path().join("native-worker");
3392        let x86 = directory.path().join("x86-worker");
3393        let arm = directory.path().join("arm-worker");
3394        std::fs::write(&native, b"native bytes").unwrap();
3395        std::fs::write(&x86, b"x86 bytes").unwrap();
3396        std::fs::write(&arm, b"arm bytes").unwrap();
3397        let cache = directory.path().join("cache");
3398        let snapshot = WorkerBinarySourceSnapshot::capture(&cache, |arch, requirement| {
3399            let path = match requirement {
3400                WorkerBinaryRequirement::LocalHost => &native,
3401                WorkerBinaryRequirement::PortableLinux if arch == "x86_64" => &x86,
3402                WorkerBinaryRequirement::PortableLinux => &arm,
3403            };
3404            Ok(WorkerBinaryAvailability::Local {
3405                path: path.clone(),
3406                source: format!("{arch}-{requirement:?}"),
3407            })
3408        });
3409
3410        let native = snapshot
3411            .resolve(std::env::consts::ARCH, WorkerBinaryRequirement::LocalHost)
3412            .unwrap();
3413        let x86 = snapshot
3414            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3415            .unwrap();
3416        let arm = snapshot
3417            .resolve("aarch64", WorkerBinaryRequirement::PortableLinux)
3418            .unwrap();
3419        let WorkerBinaryAvailability::Local { path: native, .. } = native else {
3420            panic!("native source should be local");
3421        };
3422        let WorkerBinaryAvailability::Local { path: x86, .. } = x86 else {
3423            panic!("x86 source should be local");
3424        };
3425        let WorkerBinaryAvailability::Local { path: arm, .. } = arm else {
3426            panic!("arm source should be local");
3427        };
3428        assert_eq!(std::fs::read(native).unwrap(), b"native bytes");
3429        assert_eq!(std::fs::read(x86).unwrap(), b"x86 bytes");
3430        assert_eq!(std::fs::read(arm).unwrap(), b"arm bytes");
3431    }
3432
3433    #[test]
3434    fn pinned_snapshot_survives_source_replacement_and_missing_candidate_install() {
3435        let directory = tempfile::tempdir().unwrap();
3436        let source = directory.path().join("worker");
3437        std::fs::write(&source, b"before").unwrap();
3438        let cache = directory.path().join("cache");
3439        let resolve_source = |_: &str, _: WorkerBinaryRequirement| {
3440            Ok(WorkerBinaryAvailability::Local {
3441                path: source.clone(),
3442                source: "test source".into(),
3443            })
3444        };
3445        let pinned = WorkerBinarySourceSnapshot::capture(&cache, resolve_source);
3446
3447        std::fs::write(&source, b"in-place mutation").unwrap();
3448        let WorkerBinaryAvailability::Local { path, .. } = pinned
3449            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3450            .unwrap()
3451        else {
3452            panic!("source should be local");
3453        };
3454        assert_eq!(std::fs::read(path).unwrap(), b"before");
3455
3456        let replacement = directory.path().join("replacement");
3457        std::fs::write(&replacement, b"after").unwrap();
3458        std::fs::rename(replacement, &source).unwrap();
3459        let WorkerBinaryAvailability::Local { path, .. } = pinned
3460            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3461            .unwrap()
3462        else {
3463            panic!("source should be local");
3464        };
3465        assert_eq!(std::fs::read(path).unwrap(), b"before");
3466        let fresh_replaced = WorkerBinarySourceSnapshot::capture(&cache, resolve_source);
3467        let WorkerBinaryAvailability::Local { path, .. } = fresh_replaced
3468            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3469            .unwrap()
3470        else {
3471            panic!("source should be local");
3472        };
3473        assert_eq!(std::fs::read(path).unwrap(), b"after");
3474
3475        let missing = directory.path().join("missing-worker");
3476        let missing_snapshot = WorkerBinarySourceSnapshot::capture(&cache, {
3477            let missing = missing.clone();
3478            move |_: &str, _: WorkerBinaryRequirement| {
3479                if missing.is_file() {
3480                    Ok(WorkerBinaryAvailability::Local {
3481                        path: missing.clone(),
3482                        source: "new source".into(),
3483                    })
3484                } else {
3485                    Err(anyhow::anyhow!("candidate is unavailable"))
3486                }
3487            }
3488        });
3489        std::fs::write(&missing, b"now installed").unwrap();
3490        assert!(
3491            missing_snapshot
3492                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3493                .is_err()
3494        );
3495        let fresh_snapshot = WorkerBinarySourceSnapshot::capture(&cache, {
3496            let missing = missing.clone();
3497            move |_: &str, _: WorkerBinaryRequirement| {
3498                Ok(WorkerBinaryAvailability::Local {
3499                    path: missing.clone(),
3500                    source: "new source".into(),
3501                })
3502            }
3503        });
3504        assert!(
3505            fresh_snapshot
3506                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3507                .is_ok()
3508        );
3509
3510        let remote_url = std::cell::RefCell::new("https://old.example/{target}".to_owned());
3511        let remote_snapshot = WorkerBinarySourceSnapshot::capture(
3512            &directory.path().join("remote-cache"),
3513            |arch, _| {
3514                Ok(WorkerBinaryAvailability::Remote {
3515                    url: remote_url.borrow().replace("{target}", arch),
3516                    sha256: "a".repeat(64),
3517                    triple: format!("{arch}-unknown-linux-musl"),
3518                })
3519            },
3520        );
3521        *remote_url.borrow_mut() = "https://new.example/{target}".into();
3522        let WorkerBinaryAvailability::Remote { url, .. } = remote_snapshot
3523            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3524            .unwrap()
3525        else {
3526            panic!("source should be remote");
3527        };
3528        assert_eq!(url, "https://old.example/x86_64");
3529
3530        let blocked_cache = directory.path().join("blocked-cache");
3531        std::fs::write(&blocked_cache, b"not a directory").unwrap();
3532        let failed_snapshot = WorkerBinarySourceSnapshot::capture(&blocked_cache, resolve_source);
3533        assert!(
3534            failed_snapshot
3535                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3536                .is_err()
3537        );
3538    }
3539
3540    #[test]
3541    fn dev_checkout_prefers_the_dedicated_musl_worker() {
3542        let controller = PathBuf::from("target/debug/mj");
3543        let musl = PathBuf::from("target/worker/x86_64-unknown-linux-musl/debug/mj-worker");
3544        let shared_target_worker =
3545            PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj-worker");
3546        let legacy = PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj");
3547        let present = [
3548            controller.clone(),
3549            musl.clone(),
3550            shared_target_worker,
3551            legacy,
3552        ];
3553        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3554            present.iter().any(|p| p == path)
3555        });
3556        assert_eq!(
3557            selected,
3558            Some((musl, "isolated development musl worker")),
3559            "the dedicated worker must win over legacy artifacts"
3560        );
3561    }
3562
3563    #[test]
3564    fn local_bare_may_use_a_native_worker_beside_the_controller() {
3565        let controller = PathBuf::from("target/debug/mj");
3566        let worker = PathBuf::from("target/debug/mj-worker");
3567        let selected = worker_binary_prerequisite_for_current(
3568            std::env::consts::ARCH,
3569            WorkerBinaryRequirement::LocalHost,
3570            &controller,
3571            &|path| path == controller || path == worker,
3572        )
3573        .unwrap();
3574        assert_eq!(
3575            selected,
3576            WorkerBinaryAvailability::Local {
3577                path: worker,
3578                source: "native worker beside mj".into(),
3579            }
3580        );
3581    }
3582
3583    #[test]
3584    fn local_bare_prefers_the_isolated_native_development_worker() {
3585        let controller = PathBuf::from("target/debug/mj");
3586        let worker = PathBuf::from("target/worker/debug/mj-worker");
3587        let packaged = PathBuf::from("target/debug/mj-worker");
3588        let selected = worker_binary_prerequisite_for_current(
3589            std::env::consts::ARCH,
3590            WorkerBinaryRequirement::LocalHost,
3591            &controller,
3592            &|path| path == controller || path == worker || path == packaged,
3593        )
3594        .unwrap();
3595        assert_eq!(
3596            selected,
3597            WorkerBinaryAvailability::Local {
3598                path: worker,
3599                source: "isolated native development worker".into(),
3600            }
3601        );
3602    }
3603
3604    #[cfg(target_os = "linux")]
3605    #[test]
3606    fn replaced_dev_controller_still_finds_its_musl_sibling() {
3607        let controller = PathBuf::from("target/debug/mj (deleted)");
3608        let musl = PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj");
3609        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3610            path == musl
3611        });
3612
3613        assert_eq!(selected, Some((musl, "development musl sibling")));
3614    }
3615
3616    #[cfg(target_os = "linux")]
3617    #[test]
3618    fn replaced_dev_controller_never_selects_the_new_glibc_controller_as_its_worker() {
3619        let controller = PathBuf::from("target/debug/mj (deleted)");
3620        let replacement = PathBuf::from("target/debug/mj");
3621        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3622            path == replacement
3623        });
3624
3625        assert_eq!(selected, None);
3626    }
3627
3628    /// A configured container template for the preflight tests. Only the
3629    /// platform matters here; the rest is the smallest valid template.
3630    fn container_template(platform: Option<&str>) -> mj_core::config::ContainerTemplate {
3631        mj_core::config::ContainerTemplate {
3632            image: "example.invalid/mj-test:latest".into(),
3633            pull_policy: Default::default(),
3634            platform: platform.map(str::to_owned),
3635            cpus: None,
3636            memory: None,
3637            environment: BTreeMap::new(),
3638            workspace_storage: Default::default(),
3639        }
3640    }
3641
3642    fn ssh_connection() -> mj_core::config::SshConnection {
3643        mj_core::config::SshConnection {
3644            host: "builder".into(),
3645            user: Some("dev".into()),
3646            identity_file: None,
3647            extra_args: Vec::new(),
3648        }
3649    }
3650
3651    #[test]
3652    fn recovery_workspace_uses_the_launch_directory_for_bare_targets_only() {
3653        let cwd = PathBuf::from("/workspace/session/project");
3654        let local = worker_workspace_for_recovery(
3655            &targets::TargetLocator::LocalBare {
3656                worker_root: "/workspace/session/worker".into(),
3657            },
3658            &cwd,
3659        )
3660        .expect("local bare targets need a workspace probe");
3661        assert_eq!(local.directory, cwd);
3662        assert_eq!(local.target, mj_core::state::ManagedWorktreeTarget::Local);
3663
3664        let remote = worker_workspace_for_recovery(
3665            &targets::TargetLocator::SshBare {
3666                worker_id: None,
3667                ssh: SshTarget {
3668                    destination: "dev@builder".into(),
3669                    ssh_args: vec!["-oBatchMode=yes".into()],
3670                },
3671                workspace: "/workspace/session".into(),
3672            },
3673            &cwd,
3674        )
3675        .expect("SSH bare targets need a workspace probe");
3676        assert_eq!(remote.directory, cwd);
3677        assert_eq!(
3678            remote.target,
3679            mj_core::state::ManagedWorktreeTarget::Ssh {
3680                destination: "dev@builder".into(),
3681                ssh_args: vec!["-oBatchMode=yes".into()],
3682            }
3683        );
3684
3685        assert!(
3686            worker_workspace_for_recovery(
3687                &targets::TargetLocator::LocalPodman {
3688                    container_id: "container".into(),
3689                    workspace_storage: Default::default(),
3690                },
3691                &cwd,
3692            )
3693            .is_none()
3694        );
3695        assert!(
3696            worker_workspace_for_recovery(
3697                &targets::TargetLocator::AwsEc2 {
3698                    profile: "default".into(),
3699                    region: "us-east-1".into(),
3700                    instance_id: "i-test".into(),
3701                    ssh: SshTarget {
3702                        destination: "dev@builder".into(),
3703                        ssh_args: Vec::new(),
3704                    },
3705                    workspace: "/workspace/session".into(),
3706                },
3707                &cwd,
3708            )
3709            .is_none()
3710        );
3711    }
3712
3713    #[test]
3714    fn preflight_reads_the_architecture_a_template_names() {
3715        use mj_core::config::TargetTemplate;
3716
3717        for (platform, expected) in [
3718            ("linux/arm64", "aarch64"),
3719            ("linux/arm64/v8", "aarch64"),
3720            ("linux/amd64", "x86_64"),
3721            ("aarch64", "aarch64"),
3722        ] {
3723            assert_eq!(
3724                preflight_architectures(&TargetTemplate::LocalPodman {
3725                    container: container_template(Some(platform)),
3726                }),
3727                vec![expected],
3728                "platform {platform}"
3729            );
3730        }
3731        // A named platform decides a remote container target too, so a resume
3732        // onto an arm64 container never asks about the host's architecture.
3733        assert_eq!(
3734            preflight_architectures(&TargetTemplate::SshPodman {
3735                ssh: ssh_connection(),
3736                container: container_template(Some("linux/arm64")),
3737            }),
3738            vec!["aarch64"]
3739        );
3740    }
3741
3742    #[test]
3743    fn preflight_uses_the_host_architecture_for_a_local_target() {
3744        use mj_core::config::TargetTemplate;
3745
3746        for template in [
3747            TargetTemplate::LocalBare,
3748            TargetTemplate::LocalPodman {
3749                container: container_template(None),
3750            },
3751            TargetTemplate::LocalDocker {
3752                container: container_template(None),
3753            },
3754            TargetTemplate::AppleContainer {
3755                container: container_template(None),
3756            },
3757        ] {
3758            assert_eq!(
3759                preflight_architectures(&template),
3760                vec![std::env::consts::ARCH],
3761                "{template:?}"
3762            );
3763        }
3764    }
3765
3766    #[test]
3767    fn preflight_accepts_either_linux_architecture_for_a_remote_target() {
3768        use mj_core::config::TargetTemplate;
3769
3770        // Nothing in the configuration says what a remote machine runs, so the
3771        // preflight passes as long as one architecture could be served; the
3772        // real architecture is read from the live target during provisioning.
3773        for template in [
3774            TargetTemplate::SshBare {
3775                ssh: ssh_connection(),
3776                permissions: mj_core::config::PermissionMode::Yolo,
3777                workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
3778            },
3779            TargetTemplate::SshPodman {
3780                ssh: ssh_connection(),
3781                container: container_template(None),
3782            },
3783            TargetTemplate::AwsEc2 {
3784                aws_profile: None,
3785                region: "us-east-1".into(),
3786                launch_template: "lt-mj".into(),
3787                launch_template_version: None,
3788                ssh_user: "dev".into(),
3789                address_source: Default::default(),
3790                identity_file: None,
3791                ssh_args: Vec::new(),
3792            },
3793        ] {
3794            assert_eq!(
3795                preflight_architectures(&template),
3796                vec!["x86_64", "aarch64"],
3797                "{template:?}"
3798            );
3799        }
3800    }
3801
3802    #[test]
3803    fn dev_checkout_still_finds_a_hel_named_sibling() {
3804        let controller = PathBuf::from("target/debug/hel");
3805        let musl = PathBuf::from("target/x86_64-unknown-linux-musl/debug/hel");
3806        let present = [controller.clone(), musl.clone()];
3807        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3808            present.iter().any(|p| p == path)
3809        });
3810        assert_eq!(selected, Some((musl, "development musl sibling")));
3811    }
3812
3813    /// An architecture no host builds for, so the lookup cannot take one of
3814    /// the "native mj binary" shortcuts and reaches the end on any machine.
3815    const FOREIGN_ARCH: &str = "riscv64";
3816
3817    /// A rebuilt or renamed checkout leaves a running daemon pointing at a
3818    /// path that holds nothing. Searching beside that path finds nothing and
3819    /// blames the user for a worker that may well be installed correctly.
3820    #[test]
3821    fn a_replaced_controller_is_reported_instead_of_a_missing_worker() {
3822        let stale = PathBuf::from("/src/.backup-vHXvCs/target/debug/mj (deleted)");
3823        let probed = RefCell::new(Vec::new());
3824
3825        let error = worker_binary_prerequisite_for_current(
3826            FOREIGN_ARCH,
3827            WorkerBinaryRequirement::PortableLinux,
3828            &stale,
3829            &|path| {
3830                probed.borrow_mut().push(path.to_path_buf());
3831                false
3832            },
3833        )
3834        .unwrap_err();
3835
3836        let detail = format!("{error:#}");
3837        assert!(
3838            detail.contains("was replaced or removed on disk"),
3839            "{detail}"
3840        );
3841        assert!(detail.contains("restart the Mjolnir daemon"), "{detail}");
3842        // The path is named without the kernel's deletion marker.
3843        assert!(
3844            detail.contains("/src/.backup-vHXvCs/target/debug/mj)"),
3845            "{detail}"
3846        );
3847        assert!(!detail.contains("(deleted)"), "{detail}");
3848        assert_eq!(
3849            probed.into_inner(),
3850            vec![stale],
3851            "nothing beside a path that no longer exists is worth probing"
3852        );
3853    }
3854
3855    /// The guard is about a controller path that no longer exists and nothing
3856    /// else: a controller still on disk keeps its whole sibling lookup, and
3857    /// keeps the plain "no Linux worker" answer when that lookup comes up
3858    /// empty. A present controller is never its own portable worker, so with
3859    /// nothing installed beside it the lookup ends in that plain answer.
3860    #[test]
3861    fn a_present_controller_still_looks_beside_itself() {
3862        let controller = PathBuf::from("/opt/brokk/mj");
3863        let probed = RefCell::new(Vec::new());
3864
3865        let error = worker_binary_prerequisite_for_current(
3866            FOREIGN_ARCH,
3867            WorkerBinaryRequirement::PortableLinux,
3868            &controller,
3869            &|path| {
3870                probed.borrow_mut().push(path.to_path_buf());
3871                path == controller
3872            },
3873        )
3874        .unwrap_err();
3875
3876        let probed = probed.into_inner();
3877        assert!(
3878            probed
3879                .iter()
3880                .any(|path| path.ends_with("mj-worker-riscv64-unknown-linux-musl")),
3881            "the packaged worker name must still be probed: {probed:?}"
3882        );
3883        let detail = format!("{error:#}");
3884        assert!(
3885            detail.contains("no Linux worker for riscv64-unknown-linux-musl"),
3886            "{detail}"
3887        );
3888        assert!(!detail.contains("restart the Mjolnir daemon"), "{detail}");
3889
3890        // With nothing beside it either, a present controller still gets the
3891        // generic message; only a replaced one is told to restart.
3892        let root = PathBuf::from("/");
3893        let error = worker_binary_prerequisite_for_current(
3894            FOREIGN_ARCH,
3895            WorkerBinaryRequirement::PortableLinux,
3896            &root,
3897            &|path| path == root,
3898        )
3899        .unwrap_err();
3900        let detail = format!("{error:#}");
3901        assert!(
3902            detail.contains("no Linux worker for riscv64-unknown-linux-musl"),
3903            "{detail}"
3904        );
3905        assert!(!detail.contains("restart the Mjolnir daemon"), "{detail}");
3906    }
3907
3908    const WORKER_BINARY_OVERRIDE_CHILD: &str = "MJ_WORKER_BINARY_OVERRIDE_CHILD";
3909
3910    /// The override names a worker outright, so it does not care where the
3911    /// controller lives or whether that path still exists.
3912    #[test]
3913    fn a_replaced_controller_still_honors_the_worker_binary_override() {
3914        // MJ_WORKER_BINARY is process-global and other tests resolve worker
3915        // binaries, so set it only in an exact child test.
3916        if std::env::var_os(WORKER_BINARY_OVERRIDE_CHILD).is_none() {
3917            let directory = tempfile::tempdir().unwrap();
3918            let worker = directory.path().join("mj-worker");
3919            std::fs::write(&worker, b"worker").unwrap();
3920            let test_name = format!(
3921                "{}::a_replaced_controller_still_honors_the_worker_binary_override",
3922                module_path!()
3923                    .strip_prefix("mj_controller::")
3924                    .unwrap_or(module_path!())
3925            );
3926            let output = std::process::Command::new(std::env::current_exe().unwrap())
3927                .args(["--exact", &test_name, "--nocapture"])
3928                .env(WORKER_BINARY_OVERRIDE_CHILD, "1")
3929                .env("MJ_WORKER_BINARY", &worker)
3930                .output()
3931                .unwrap();
3932            assert!(
3933                output.status.success(),
3934                "isolated worker override test failed\nstdout:\n{}\nstderr:\n{}",
3935                String::from_utf8_lossy(&output.stdout),
3936                String::from_utf8_lossy(&output.stderr)
3937            );
3938            return;
3939        }
3940
3941        let stale = PathBuf::from("/src/.backup-vHXvCs/target/debug/mj (deleted)");
3942        let availability = worker_binary_prerequisite_for_current(
3943            FOREIGN_ARCH,
3944            WorkerBinaryRequirement::PortableLinux,
3945            &stale,
3946            &|path| path.is_file(),
3947        )
3948        .unwrap();
3949
3950        match availability {
3951            WorkerBinaryAvailability::Local { source, .. } => {
3952                assert_eq!(source, "MJ_WORKER_BINARY");
3953            }
3954            other => panic!("expected the override to resolve, got {other:?}"),
3955        }
3956    }
3957
3958    #[test]
3959    fn sibling_lookup_falls_back_to_the_legacy_hel_name_beside_an_mj_controller() {
3960        let controller = PathBuf::from("/opt/brokk/mj");
3961        let legacy = PathBuf::from("/opt/brokk/hel");
3962        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3963            path == legacy
3964        });
3965        assert_eq!(selected, Some((legacy, "beside the running executable")));
3966    }
3967
3968    #[test]
3969    fn worker_diagnosis_surfaces_a_loader_failure_from_the_installed_binary() {
3970        struct FailedProbe;
3971
3972        impl CommandExecutor for FailedProbe {
3973            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
3974                Ok(CommandOutput {
3975                    status: 1,
3976                    stdout: Vec::new(),
3977                    stderr: b"libc.so.6: version `GLIBC_2.39' not found\n".to_vec(),
3978                })
3979            }
3980        }
3981
3982        let failure = worker_binary_probe_failure(
3983            &FailedProbe,
3984            &targets::TargetLocator::LocalBare {
3985                worker_root: "/worker/root".into(),
3986            },
3987            "/worker/root",
3988        )
3989        .expect("an unsuccessful --version probe should explain the dead worker");
3990
3991        assert!(failure.contains("GLIBC_2.39"), "{failure}");
3992        assert!(failure.contains("provide a musl worker"), "{failure}");
3993    }
3994
3995    /// macOS puts worker roots under `~/Library/Application Support/...`.
3996    /// An unquoted root split the diagnostic script into separate words, so
3997    /// the probe silently reported nothing exactly when it was needed.
3998    #[test]
3999    fn worker_last_words_reads_a_root_containing_spaces() {
4000        struct RecordingExecutor {
4001            commands: RefCell<Vec<CommandSpec>>,
4002        }
4003
4004        impl CommandExecutor for RecordingExecutor {
4005            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4006                self.commands.borrow_mut().push(command.clone());
4007                Ok(CommandOutput {
4008                    status: 0,
4009                    stdout: Vec::new(),
4010                    stderr: Vec::new(),
4011                })
4012            }
4013        }
4014
4015        let temp = tempfile::tempdir().unwrap();
4016        let root = temp.path().join("Application Support").join("hel worker");
4017        std::fs::create_dir_all(&root).unwrap();
4018        std::fs::write(
4019            root.join("worker-exit.json"),
4020            b"{\n  \"reason\": \"panic\"\n}\n",
4021        )
4022        .unwrap();
4023        std::fs::write(
4024            root.join("worker.log"),
4025            b"Mjolnir worker exited with an error\n",
4026        )
4027        .unwrap();
4028        let root = root.to_str().unwrap();
4029
4030        let locator = targets::TargetLocator::LocalBare {
4031            worker_root: root.into(),
4032        };
4033        let reported = worker_last_words(&ProcessExecutor, &locator, root)
4034            .expect("the probe reads a root containing spaces");
4035        assert!(reported.contains(WORKER_EXIT_RECORD_MARKER), "{reported}");
4036        assert!(reported.contains("\"reason\": \"panic\""), "{reported}");
4037        assert!(
4038            reported.contains("Mjolnir worker exited with an error"),
4039            "{reported}"
4040        );
4041
4042        let recorder = RecordingExecutor {
4043            commands: RefCell::new(Vec::new()),
4044        };
4045        worker_last_words(&recorder, &locator, root);
4046        let commands = recorder.commands.borrow();
4047        let script = commands
4048            .iter()
4049            .flat_map(|command| command.args.iter())
4050            .find(|argument| argument.contains("worker-exit.json"))
4051            .expect("the probe builds a diagnostic script");
4052        assert!(
4053            script.contains(&format!("'{root}'")),
4054            "the root must be single-quoted: {script}"
4055        );
4056    }
4057
4058    /// A worker that died leaves an exit record behind. Starting a new worker
4059    /// must clear it first, or the startup connect loop reads the previous
4060    /// death as this worker's and gives up on a healthy daemon.
4061    #[test]
4062    fn starting_a_worker_clears_stale_runtime_files_before_launching() {
4063        struct RecordingExecutor {
4064            commands: RefCell<Vec<CommandSpec>>,
4065        }
4066
4067        impl CommandExecutor for RecordingExecutor {
4068            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4069                self.commands.borrow_mut().push(command.clone());
4070                Ok(CommandOutput {
4071                    status: 0,
4072                    stdout: Vec::new(),
4073                    stderr: Vec::new(),
4074                })
4075            }
4076        }
4077
4078        for locator in [
4079            targets::TargetLocator::LocalBare {
4080                worker_root: "/worker/root".into(),
4081            },
4082            targets::TargetLocator::LocalPodman {
4083                container_id: "container-1".into(),
4084                workspace_storage: Default::default(),
4085            },
4086        ] {
4087            let executor = RecordingExecutor {
4088                commands: RefCell::new(Vec::new()),
4089            };
4090            start_worker(&executor, &locator, "/worker/root").unwrap();
4091
4092            let commands = executor.commands.borrow();
4093            let script = commands
4094                .iter()
4095                .flat_map(|command| command.args.iter())
4096                .find(|argument| argument.contains("worker-exit.json"))
4097                .unwrap_or_else(|| {
4098                    panic!("no launch script cleared the exit record: {commands:?}")
4099                });
4100            let cleared = script.find("rm -f").expect("the exit record is removed");
4101            let launched = script.find("worker").expect("the daemon is launched");
4102            assert!(
4103                script.contains("control.sock"),
4104                "the stale relay endpoint must be cleared before startup: {script}"
4105            );
4106            assert!(
4107                cleared < launched,
4108                "stale runtime files must be cleared before the daemon starts: {script}"
4109            );
4110        }
4111    }
4112    #[test]
4113    fn stopping_a_worker_runs_the_daemon_stop_script() {
4114        struct RecordingExecutor {
4115            commands: RefCell<Vec<CommandSpec>>,
4116        }
4117
4118        impl CommandExecutor for RecordingExecutor {
4119            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4120                self.commands.borrow_mut().push(command.clone());
4121                Ok(CommandOutput {
4122                    status: 0,
4123                    stdout: Vec::new(),
4124                    stderr: Vec::new(),
4125                })
4126            }
4127        }
4128
4129        let locator = targets::TargetLocator::SshBare {
4130            worker_id: None,
4131            ssh: SshTarget {
4132                destination: "user@example.test".into(),
4133                ssh_args: Vec::new(),
4134            },
4135            workspace: "/workspace".into(),
4136        };
4137        let executor = RecordingExecutor {
4138            commands: RefCell::new(Vec::new()),
4139        };
4140        stop_worker(&executor, &locator, "/worker/root").unwrap();
4141
4142        let commands = executor.commands.borrow();
4143        assert_eq!(commands.len(), 1);
4144        assert_eq!(commands[0].purpose, "stop Mjolnir worker daemon");
4145        assert!(
4146            commands[0]
4147                .args
4148                .last()
4149                .is_some_and(|remote| remote.starts_with("'sh' '-c' ")),
4150            "raw SSH worker management must not source login profiles: {commands:?}"
4151        );
4152        let script = commands[0]
4153            .args
4154            .iter()
4155            .find(|argument| argument.contains("worker run --root"))
4156            .unwrap_or_else(|| panic!("stop script missing from {commands:?}"));
4157        assert!(
4158            script.contains("hel_match=\"hel worker run --root $hel_root\""),
4159            "stop must match only this session's worker: {script}"
4160        );
4161        assert!(
4162            script.contains("hel_match_home=\"hel worker run --root $HOME/$hel_root\""),
4163            "stop must also match a login-home-absolute --root: {script}"
4164        );
4165        assert!(
4166            !script.contains("grep -F"),
4167            "leftover detection must not grep the match string: {script}"
4168        );
4169    }
4170    #[test]
4171    fn checkpoint_worker_stop_restores_a_stopped_podman_target_first() {
4172        struct RecordingExecutor {
4173            commands: RefCell<Vec<CommandSpec>>,
4174            outputs: RefCell<Vec<CommandOutput>>,
4175        }
4176
4177        impl CommandExecutor for RecordingExecutor {
4178            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4179                self.commands.borrow_mut().push(command.clone());
4180                Ok(self.outputs.borrow_mut().remove(0))
4181            }
4182        }
4183
4184        let session = "0123456789abcdef0123456789abcdef";
4185        let container_id = targets::resource_name(session).unwrap();
4186        let inspection = |status: &str| CommandOutput {
4187            status: 0,
4188            stdout: serde_json::to_vec(&serde_json::json!([{
4189                "Config": { "Labels": {
4190                    (targets::MANAGED_LABEL): "true",
4191                    (targets::SESSION_LABEL): session,
4192                }},
4193                "State": { "Status": status },
4194            }]))
4195            .unwrap(),
4196            stderr: Vec::new(),
4197        };
4198        let executor = RecordingExecutor {
4199            commands: RefCell::new(Vec::new()),
4200            outputs: RefCell::new(vec![
4201                CommandOutput {
4202                    status: 0,
4203                    stdout: Vec::new(),
4204                    stderr: Vec::new(),
4205                },
4206                inspection("exited"),
4207                CommandOutput {
4208                    status: 0,
4209                    stdout: Vec::new(),
4210                    stderr: Vec::new(),
4211                },
4212                inspection("running"),
4213                CommandOutput {
4214                    status: 0,
4215                    stdout: Vec::new(),
4216                    stderr: Vec::new(),
4217                },
4218            ]),
4219        };
4220        let locator = targets::TargetLocator::LocalPodman {
4221            container_id,
4222            workspace_storage: Default::default(),
4223        };
4224
4225        stop_worker_after_target_recovery(&executor, &locator, session, "/worker/root").unwrap();
4226
4227        let commands = executor.commands.borrow();
4228        let purposes = commands
4229            .iter()
4230            .map(|command| command.purpose.as_str())
4231            .collect::<Vec<_>>();
4232        assert_eq!(
4233            purposes,
4234            [
4235                "check for Mjolnir session container",
4236                "inspect Mjolnir session container",
4237                "start stopped Mjolnir session container",
4238                "inspect Mjolnir session container",
4239                "stop Mjolnir worker daemon",
4240            ]
4241        );
4242    }
4243
4244    struct PodmanInstallExecutor {
4245        commands: RefCell<Vec<CommandSpec>>,
4246        worker_cached: bool,
4247    }
4248    impl CommandExecutor for PodmanInstallExecutor {
4249        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4250            self.commands.borrow_mut().push(command.clone());
4251            let probing_cache = command
4252                .args
4253                .iter()
4254                .any(|argument| argument.contains("'test' '-f'"));
4255            let status = if probing_cache && !self.worker_cached {
4256                1
4257            } else {
4258                0
4259            };
4260            Ok(CommandOutput {
4261                status,
4262                stdout: Vec::new(),
4263                stderr: Vec::new(),
4264            })
4265        }
4266    }
4267    struct PodmanInstallFixture {
4268        _root: tempfile::TempDir,
4269        worker_binary: PathBuf,
4270        launch_config: PathBuf,
4271        ownership: PathBuf,
4272        profile_stage: PathBuf,
4273        locator: targets::TargetLocator,
4274        digest: String,
4275    }
4276    fn podman_install_fixture() -> PodmanInstallFixture {
4277        let root = tempfile::tempdir().unwrap();
4278        let worker_binary = root.path().join("hel");
4279        std::fs::write(&worker_binary, b"worker-binary-bytes").unwrap();
4280        let launch_config = root.path().join("launch.json");
4281        std::fs::write(&launch_config, b"{}").unwrap();
4282        let ownership = root.path().join("ownership.json");
4283        std::fs::write(&ownership, b"{}").unwrap();
4284        let profile_stage = root.path().join("profile");
4285        std::fs::create_dir_all(&profile_stage).unwrap();
4286        let digest = format!("{:x}", Sha256::digest(b"worker-binary-bytes"));
4287        PodmanInstallFixture {
4288            _root: root,
4289            worker_binary,
4290            launch_config,
4291            ownership,
4292            profile_stage,
4293            locator: targets::TargetLocator::SshPodman {
4294                ssh: SshTarget {
4295                    destination: "user@example.test".into(),
4296                    ssh_args: Vec::new(),
4297                },
4298                container_id: "container-1".into(),
4299                workspace_storage: Default::default(),
4300            },
4301            digest,
4302        }
4303    }
4304    fn run_podman_install(worker_cached: bool) -> (Vec<CommandSpec>, PodmanInstallFixture) {
4305        let fixture = podman_install_fixture();
4306        let executor = PodmanInstallExecutor {
4307            commands: RefCell::new(Vec::new()),
4308            worker_cached,
4309        };
4310        install_worker_files(
4311            &executor,
4312            &fixture.locator,
4313            "0123456789abcdef0123456789abcdef",
4314            "/workspace/.hel/worker",
4315            "/workspace/.hel/profile",
4316            &fixture.worker_binary,
4317            &fixture.launch_config,
4318            &fixture.ownership,
4319            &fixture.profile_stage,
4320        )
4321        .unwrap();
4322        let commands = executor.commands.borrow().clone();
4323        (commands, fixture)
4324    }
4325    fn rendered(commands: &[CommandSpec]) -> Vec<String> {
4326        commands
4327            .iter()
4328            .map(|command| format!("{} {}", command.program, command.args.join(" ")))
4329            .collect()
4330    }
4331    #[test]
4332    fn ssh_podman_install_caches_the_worker_binary_on_a_cache_miss() {
4333        let (commands, fixture) = run_podman_install(false);
4334        let lines = rendered(&commands);
4335        let digest = &fixture.digest;
4336        let cache_dir = format!(".cache/mjolnir/workers/{digest}");
4337        let session = "0123456789abcdef0123456789abcdef";
4338
4339        assert!(
4340            lines
4341                .iter()
4342                .any(|line| line.starts_with("ssh") && line.contains("'test' '-f'")),
4343            "expected a cache probe, got {lines:#?}"
4344        );
4345        assert!(
4346            !lines.iter().any(|line| line.contains('~')),
4347            "remote staging paths must be home-relative: ssh arguments are \
4348                 single-quoted so a tilde stays literal in the remote shell while \
4349                 scp expands it, got {lines:#?}"
4350        );
4351        assert!(
4352            lines.iter().any(|line| line.starts_with("ssh")
4353                && line.contains(&format!("'mkdir' '-p' '{cache_dir}'"))),
4354            "expected the cache directory to be created, got {lines:#?}"
4355        );
4356        let partial = format!("{cache_dir}/hel.partial-{session}");
4357        assert!(
4358            lines.iter().any(|line| line
4359                == &format!(
4360                    "scp {} user@example.test:{partial}",
4361                    fixture.worker_binary.display()
4362                )),
4363            "expected the worker to be uploaded to the partial cache path, got {lines:#?}"
4364        );
4365        assert!(
4366            lines.iter().any(|line| line.starts_with("ssh")
4367                && line.contains(&format!("'mv' '{partial}' '{cache_dir}/hel'"))),
4368            "expected an atomic rename into the cache, got {lines:#?}"
4369        );
4370        assert!(
4371            lines.iter().any(|line| line.contains("'podman' 'cp'")
4372                && line.contains(&format!("'{cache_dir}/hel'"))),
4373            "expected podman cp to read the cached worker, got {lines:#?}"
4374        );
4375        assert!(
4376            !lines.iter().any(|line| line.starts_with("scp")
4377                && line.ends_with(&format!(
4378                    "user@example.test:.cache/mjolnir/uploads/{session}/hel"
4379                ))),
4380            "the worker must not be staged in the per-session upload directory, got {lines:#?}"
4381        );
4382    }
4383    #[test]
4384    fn ssh_podman_install_skips_the_worker_upload_on_a_cache_hit() {
4385        let (commands, fixture) = run_podman_install(true);
4386        let lines = rendered(&commands);
4387        let digest = &fixture.digest;
4388        let cache_dir = format!(".cache/mjolnir/workers/{digest}");
4389        let session = "0123456789abcdef0123456789abcdef";
4390
4391        assert!(
4392            !lines.iter().any(|line| line.starts_with("scp")
4393                && line.contains(&fixture.worker_binary.display().to_string())),
4394            "a cached worker must not be re-uploaded, got {lines:#?}"
4395        );
4396        assert!(
4397            !lines.iter().any(|line| line.contains("'mv'")),
4398            "a cache hit must not rename anything, got {lines:#?}"
4399        );
4400        assert!(
4401            lines.iter().any(|line| line.contains("'podman' 'cp'")
4402                && line.contains(&format!("'{cache_dir}/hel'"))),
4403            "expected podman cp to read the cached worker, got {lines:#?}"
4404        );
4405        for name in ["launch.json", "ownership.json"] {
4406            assert!(
4407                lines.iter().any(|line| line.starts_with("scp")
4408                    && line.ends_with(&format!(
4409                        "user@example.test:.cache/mjolnir/uploads/{session}/{name}"
4410                    ))),
4411                "expected {name} to still be uploaded per session, got {lines:#?}"
4412            );
4413        }
4414    }
4415
4416    #[test]
4417    fn ssh_docker_install_uses_docker_for_remote_container_operations() {
4418        let mut fixture = podman_install_fixture();
4419        fixture.locator = targets::TargetLocator::SshDocker {
4420            ssh: SshTarget {
4421                destination: "user@example.test".into(),
4422                ssh_args: Vec::new(),
4423            },
4424            container_id: "container-1".into(),
4425        };
4426        let executor = PodmanInstallExecutor {
4427            commands: RefCell::new(Vec::new()),
4428            worker_cached: true,
4429        };
4430        install_worker_files(
4431            &executor,
4432            &fixture.locator,
4433            "0123456789abcdef0123456789abcdef",
4434            "/workspace/.hel/worker",
4435            "/workspace/.hel/profile",
4436            &fixture.worker_binary,
4437            &fixture.launch_config,
4438            &fixture.ownership,
4439            &fixture.profile_stage,
4440        )
4441        .unwrap();
4442
4443        let lines = rendered(&executor.commands.borrow());
4444        assert!(
4445            lines.iter().any(|line| line.contains("'docker' 'cp'")),
4446            "expected Docker to copy the cached worker, got {lines:#?}"
4447        );
4448        assert!(
4449            lines.iter().any(|line| line.contains("'docker' 'exec'")),
4450            "expected Docker to prepare the worker directories, got {lines:#?}"
4451        );
4452        assert!(
4453            !lines.iter().any(|line| line.contains("'podman'")),
4454            "Docker installation accidentally used Podman: {lines:#?}"
4455        );
4456    }
4457
4458    #[test]
4459    #[ignore = "requires Docker and the locally installed agent-dev image"]
4460    fn docker_uploads_and_replacements_are_usable_by_the_non_root_worker() {
4461        let fixture = podman_install_fixture();
4462        let session = mj_core::state::new_session_id().unwrap();
4463        let container_id = targets::resource_name(&session).unwrap();
4464        let locator = targets::TargetLocator::LocalDocker {
4465            container_id: container_id.clone(),
4466        };
4467        execute_checked(
4468            &ProcessExecutor,
4469            CommandSpec::new(
4470                "docker",
4471                [
4472                    "run",
4473                    "--pull=never",
4474                    "-d",
4475                    "--name",
4476                    &container_id,
4477                    "ghcr.io/brokkai/mjolnir/agent-dev:latest",
4478                    "sleep",
4479                    "infinity",
4480                ],
4481            ),
4482        )
4483        .unwrap();
4484        let result = (|| -> Result<()> {
4485            let root = targets::worker_root(&locator, &session)?;
4486            let profile = format!("{root}/profile");
4487            std::fs::write(fixture.profile_stage.join("credential"), "private")?;
4488            install_worker_files(
4489                &ProcessExecutor,
4490                &locator,
4491                &session,
4492                &root,
4493                &profile,
4494                &fixture.worker_binary,
4495                &fixture.launch_config,
4496                &fixture.ownership,
4497                &fixture.profile_stage,
4498            )?;
4499            replace_installed_worker_binary(
4500                &ProcessExecutor,
4501                &locator,
4502                &session,
4503                &fixture.worker_binary,
4504            )?;
4505            execute_checked(
4506                &ProcessExecutor,
4507                CommandSpec::new(
4508                    "docker",
4509                    [
4510                        "exec",
4511                        &container_id,
4512                        "sh",
4513                        "-c",
4514                        "test \"$(id -u)\" != 0 && test -x \"$1/hel\" && test -r \"$1/launch.json\" && test -r \"$1/ownership.json\" && test -r \"$1/profile/credential\" && test -w \"$1/profile/credential\"",
4515                        "sh",
4516                        &root,
4517                    ],
4518                ),
4519            )?;
4520            Ok(())
4521        })();
4522        let cleanup = execute_checked(
4523            &ProcessExecutor,
4524            CommandSpec::new("docker", ["rm", "-f", &container_id]),
4525        );
4526        result.unwrap();
4527        cleanup.unwrap();
4528    }
4529
4530    #[test]
4531    fn replacing_an_installed_podman_worker_writes_through_a_next_path() {
4532        struct RecordingExecutor {
4533            commands: RefCell<Vec<CommandSpec>>,
4534        }
4535        impl CommandExecutor for RecordingExecutor {
4536            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4537                self.commands.borrow_mut().push(command.clone());
4538                Ok(CommandOutput {
4539                    status: 0,
4540                    stdout: Vec::new(),
4541                    stderr: Vec::new(),
4542                })
4543            }
4544        }
4545
4546        let session = "0123456789abcdef0123456789abcdef";
4547        let container_id = targets::resource_name(session).unwrap();
4548        let locator = targets::TargetLocator::LocalPodman {
4549            container_id: container_id.clone(),
4550            workspace_storage: Default::default(),
4551        };
4552        let executor = RecordingExecutor {
4553            commands: RefCell::new(Vec::new()),
4554        };
4555        replace_installed_worker_binary(&executor, &locator, session, Path::new("/controller/hel"))
4556            .unwrap();
4557
4558        let mut lines = rendered(&executor.commands.borrow());
4559        let ownership = lines.remove(1);
4560        assert!(ownership.starts_with(&format!("podman exec --user 0 {container_id} sh -c")));
4561        assert!(ownership.contains("chown -R"));
4562        assert!(ownership.ends_with(&format!("/var/lib/hel/workers/{session}/hel.next")));
4563        assert_eq!(
4564            lines,
4565            vec![
4566                format!(
4567                    "podman cp /controller/hel {container_id}:/var/lib/hel/workers/{session}/hel.next"
4568                ),
4569                format!(
4570                    "podman exec {container_id} mv -f /var/lib/hel/workers/{session}/hel.next /var/lib/hel/workers/{session}/hel"
4571                ),
4572                format!("podman exec {container_id} chmod 700 /var/lib/hel/workers/{session}/hel"),
4573            ]
4574        );
4575    }
4576    #[test]
4577    fn default_bridges_pin_command_capable_adapter_versions() {
4578        let (codex_command, codex_arguments) = bridge_launch(
4579            mj_core::config::HarnessKind::Codex,
4580            ExecutionPolicy::Unconstrained,
4581        );
4582        assert_eq!(codex_command, "sh");
4583        assert_eq!(codex_arguments[0], "-c");
4584        assert!(codex_arguments[1].contains("@brokkai/codex-acp@1.11.4"));
4585        assert!(codex_arguments[1].contains("codex-acp --version"));
4586        assert!(codex_arguments[1].contains("npx -y @brokkai/codex-acp@1.11.4"));
4587
4588        let (claude_command, claude_arguments) = bridge_launch(
4589            mj_core::config::HarnessKind::Claude,
4590            ExecutionPolicy::Unconstrained,
4591        );
4592        assert_eq!(claude_command, "sh");
4593        assert_eq!(claude_arguments[0], "-c");
4594        assert!(claude_arguments[1].contains("@agentclientprotocol/claude-agent-acp@0.73.0"));
4595    }
4596
4597    #[test]
4598    fn readiness_stage_names_only_install_capable_default_harnesses() {
4599        let profile = |kind| mj_core::config::HarnessProfile {
4600            enabled: true,
4601            kind,
4602            home: PathBuf::from("/profiles/test"),
4603            environment: BTreeMap::new(),
4604            context_window_bytes: None,
4605            guardian_review_model: None,
4606        };
4607
4608        for harness in HarnessKind::ALL {
4609            assert_eq!(
4610                bridge_readiness_stage(&profile(harness)),
4611                ProvisionStage::Installing(harness)
4612            );
4613        }
4614    }
4615    #[test]
4616    fn codex_execution_environment_follows_the_target_policy() {
4617        let mut podman_environment =
4618            BTreeMap::from([("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned())]);
4619        mj_core::config::HarnessKind::Codex
4620            .configure_execution_environment(
4621                ExecutionPolicy::Unconstrained,
4622                &mut podman_environment,
4623            )
4624            .unwrap();
4625        assert_eq!(
4626            podman_environment
4627                .get("INITIAL_AGENT_MODE")
4628                .map(String::as_str),
4629            Some("agent-full-access")
4630        );
4631
4632        let mut bare_environment =
4633            BTreeMap::from([("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned())]);
4634        mj_core::config::HarnessKind::Codex
4635            .configure_execution_environment(
4636                ExecutionPolicy::ConfiguredApprovals,
4637                &mut bare_environment,
4638            )
4639            .unwrap();
4640        assert_eq!(
4641            bare_environment
4642                .get("INITIAL_AGENT_MODE")
4643                .map(String::as_str),
4644            Some("agent"),
4645            "Codex uses guardian on raw localhost"
4646        );
4647    }
4648    #[test]
4649    fn bare_targets_use_managed_harnesses_but_containers_stay_ambient() {
4650        let ssh = SshTarget {
4651            destination: "user@example.test".into(),
4652            ssh_args: Vec::new(),
4653        };
4654        let targets = [
4655            (
4656                targets::TargetLocator::LocalBare {
4657                    worker_root: "/worker".into(),
4658                },
4659                HarnessRuntimePolicy::Managed,
4660            ),
4661            (
4662                targets::TargetLocator::LocalPodman {
4663                    container_id: "container".into(),
4664                    workspace_storage: Default::default(),
4665                },
4666                HarnessRuntimePolicy::Ambient,
4667            ),
4668            (
4669                targets::TargetLocator::SshBare {
4670                    worker_id: None,
4671                    ssh: ssh.clone(),
4672                    workspace: "/workspace/session".into(),
4673                },
4674                HarnessRuntimePolicy::Managed,
4675            ),
4676            (
4677                targets::TargetLocator::AwsEc2 {
4678                    profile: "profile".into(),
4679                    region: "us-east-1".into(),
4680                    instance_id: "i-test".into(),
4681                    ssh,
4682                    workspace: "/workspace/session".into(),
4683                },
4684                HarnessRuntimePolicy::Managed,
4685            ),
4686        ];
4687
4688        for (target, expected) in targets {
4689            assert_eq!(harness_runtime_policy(&target), expected, "{target:?}");
4690        }
4691    }
4692    #[test]
4693    fn grok_sandbox_environment_follows_the_target_policy() {
4694        let mut isolated = BTreeMap::from([("GROK_SANDBOX".to_owned(), "strict".to_owned())]);
4695        mj_core::config::HarnessKind::Grok
4696            .configure_execution_environment(ExecutionPolicy::Unconstrained, &mut isolated)
4697            .unwrap();
4698        assert_eq!(
4699            isolated.get("GROK_SANDBOX").map(String::as_str),
4700            Some("off")
4701        );
4702
4703        let mut local = BTreeMap::from([("GROK_SANDBOX".to_owned(), "strict".to_owned())]);
4704        mj_core::config::HarnessKind::Grok
4705            .configure_execution_environment(ExecutionPolicy::ConfiguredApprovals, &mut local)
4706            .unwrap();
4707        assert_eq!(
4708            local.get("GROK_SANDBOX").map(String::as_str),
4709            Some("strict"),
4710            "raw localhost must preserve the profile's configured sandbox"
4711        );
4712    }
4713    #[test]
4714    fn bridge_fallback_pins_match_the_agent_dev_containerfile() {
4715        const CONTAINERFILE: &str = include_str!("../../../containers/Containerfile.agent-dev");
4716
4717        let codex = format!("codex-acp@{CODEX_ACP_VERSION}");
4718        assert!(
4719            CONTAINERFILE.contains(&codex),
4720            "containers/Containerfile.agent-dev must install {codex}. The image and the \
4721                 bridge_launch() npx fallbacks have to stay in lockstep, otherwise a container \
4722                 session and an npx session run different adapter versions."
4723        );
4724
4725        let claude = format!("claude-agent-acp@{CLAUDE_ACP_VERSION}");
4726        assert!(
4727            CONTAINERFILE.contains(&claude),
4728            "containers/Containerfile.agent-dev must install {claude}. The image and the \
4729                 bridge_launch() npx fallbacks have to stay in lockstep, otherwise a container \
4730                 session and an npx session run different adapter versions."
4731        );
4732    }
4733    #[test]
4734    fn kimi_default_bridge_is_non_login_and_uses_bash_for_the_official_installer() {
4735        let (command, arguments) = bridge_launch(
4736            mj_core::config::HarnessKind::Kimi,
4737            ExecutionPolicy::Unconstrained,
4738        );
4739        assert_eq!(command, "sh");
4740        assert_eq!(arguments[0], "-c");
4741        assert!(arguments[1].contains("install.sh | bash &&"));
4742        assert!(arguments[1].contains("$HOME/.kimi-code/bin/kimi"));
4743        assert!(arguments[1].contains("Mjolnir needs compatible Kimi Code"));
4744        assert!(!arguments[1].contains("Hel"));
4745    }
4746    #[test]
4747    fn grok_default_bridge_is_non_login_and_uses_bash_for_the_official_installer() {
4748        let (command, arguments) = bridge_launch(
4749            mj_core::config::HarnessKind::Grok,
4750            ExecutionPolicy::ConfiguredApprovals,
4751        );
4752        assert_eq!(command, "sh");
4753        assert_eq!(arguments[0], "-c");
4754        let script = &arguments[1];
4755        assert!(script.contains("https://x.ai/cli/install.sh | bash &&"));
4756        assert!(script.contains("command -v grok"));
4757        assert!(script.contains("[ -x \"$GROK_HOME/bin/grok\" ]"));
4758        assert!(script.contains("[ -x \"$HOME/.grok/bin/grok\" ]"));
4759        assert!(script.contains("exit 127"));
4760        assert!(script.contains("exec grok agent stdio"));
4761        assert!(!script.contains("--always-approve"));
4762        assert!(script.contains("Mjolnir needs compatible Grok Build"));
4763        assert!(!script.contains("Hel"));
4764    }
4765    #[test]
4766    fn node_bootstrap_errors_name_mjolnir() {
4767        let script = ensure_node_script();
4768        assert!(script.contains("Mjolnir needs Node.js, npm, and npx"));
4769        assert!(!script.contains("sudo"));
4770        assert!(!script.contains("apt-get"));
4771        assert!(!script.contains("Hel"));
4772    }
4773    #[test]
4774    fn grok_default_bridge_adds_the_always_approve_flag_when_unrestricted() {
4775        let (_, arguments) = bridge_launch(
4776            mj_core::config::HarnessKind::Grok,
4777            ExecutionPolicy::Unconstrained,
4778        );
4779        let script = &arguments[1];
4780        assert!(script.contains("exec grok agent --always-approve stdio"));
4781        assert!(script.contains("exec \"$GROK_HOME/bin/grok\" agent --always-approve stdio"));
4782        assert!(script.contains("exec \"$HOME/.grok/bin/grok\" agent --always-approve stdio"));
4783    }
4784    #[test]
4785    fn kimi_uses_runtime_aware_memory_delivery_only_on_staged_targets() {
4786        let local = targets::TargetLocator::LocalBare {
4787            worker_root: "/worker".into(),
4788        };
4789        let podman = targets::TargetLocator::LocalPodman {
4790            container_id: "container".into(),
4791            workspace_storage: Default::default(),
4792        };
4793
4794        assert_eq!(
4795            project_memory_mcp_delivery(mj_core::config::HarnessKind::Kimi, &local),
4796            ProjectMemoryMcpDelivery::Acp
4797        );
4798        assert_eq!(
4799            project_memory_mcp_delivery(mj_core::config::HarnessKind::Kimi, &podman),
4800            ProjectMemoryMcpDelivery::HarnessProfile
4801        );
4802        assert_eq!(
4803            project_memory_mcp_delivery(mj_core::config::HarnessKind::Codex, &podman),
4804            ProjectMemoryMcpDelivery::Acp
4805        );
4806    }
4807    /// A catalog cache backed by an isolated copy of Mjolnir's own
4808    /// `profile_config_cache` table, so the fallback path is exercised against
4809    /// the real schema without touching the live store.
4810    struct IsolatedCatalogCache(std::path::PathBuf);
4811
4812    impl CatalogCache for IsolatedCatalogCache {
4813        fn load(&self, profile_id: &str, fingerprint: &str) -> Option<String> {
4814            crate::database::load_profile_config_cache_from(&self.0, profile_id, "", fingerprint)
4815                .ok()
4816                .flatten()
4817        }
4818
4819        fn store(&self, profile_id: &str, fingerprint: &str, body: &str) {
4820            crate::database::save_profile_config_cache_at(
4821                &self.0,
4822                profile_id,
4823                "",
4824                fingerprint,
4825                body,
4826            )
4827            .expect("write the isolated catalog cache");
4828        }
4829    }
4830
4831    const ZAI_CONFIG: &str = "model = \"glm-5.3\"\n\
4832                              model_provider = \"zai\"\n\
4833                              \n\
4834                              [model_providers.zai]\n\
4835                              base_url = \"https://api.z.ai/api/v1\"\n\
4836                              env_key = \"ZAI_API_KEY\"\n\
4837                              wire_api = \"responses\"\n";
4838
4839    const ZAI_CATALOG: &str = r#"{"models":[
4840        {"slug":"glm-5.3","supported_reasoning_levels":["low","high","max"]},
4841        {"slug":"glm-5.3-flash","supported_reasoning_levels":["low","high","max"]}
4842    ]}"#;
4843
4844    fn zai_profile(home: &Path) -> mj_core::config::HarnessProfile {
4845        std::fs::write(home.join("config.toml"), ZAI_CONFIG).unwrap();
4846        mj_core::config::HarnessProfile {
4847            enabled: true,
4848            kind: mj_core::config::HarnessKind::Codex,
4849            home: home.to_path_buf(),
4850            environment: BTreeMap::from([("ZAI_API_KEY".to_owned(), "coding-plan-key".to_owned())]),
4851            context_window_bytes: None,
4852            guardian_review_model: None,
4853        }
4854    }
4855
4856    #[test]
4857    fn staging_a_custom_provider_profile_writes_a_catalog_the_session_can_pick_from() {
4858        let home = tempfile::tempdir().unwrap();
4859        let staged = tempfile::tempdir().unwrap();
4860        let cache = tempfile::tempdir().unwrap();
4861        let profile = zai_profile(home.path());
4862        let cache = IsolatedCatalogCache(cache.path().join("cache.sqlite3"));
4863        let asked = std::cell::RefCell::new(Vec::new());
4864
4865        stage_profile(&profile, staged.path()).unwrap();
4866        stage_codex_catalog(
4867            "glm",
4868            &profile,
4869            staged.path(),
4870            &|url, key| {
4871                asked.borrow_mut().push((url.to_owned(), key.to_owned()));
4872                Ok(ZAI_CATALOG.as_bytes().to_vec())
4873            },
4874            &cache,
4875        )
4876        .unwrap();
4877
4878        assert_eq!(
4879            asked.into_inner(),
4880            vec![(
4881                "https://api.z.ai/api/v1/models".to_owned(),
4882                "coding-plan-key".to_owned()
4883            )],
4884            "the provider's own key authorizes its catalog fetch"
4885        );
4886        let catalog = mj_core::codex_catalog::parse(
4887            &std::fs::read(staged.path().join("models.json")).unwrap(),
4888        )
4889        .unwrap();
4890        assert_eq!(catalog.slugs(), ["glm-5.3", "glm-5.3-flash"]);
4891        for model in &catalog.models {
4892            assert_eq!(
4893                model["auto_review_model_override"],
4894                serde_json::Value::from("glm-5.3-flash"),
4895                "Guardian reviews run on the newest flash model"
4896            );
4897        }
4898        // The key must be top-level, so it precedes the provider table, and the
4899        // user's own lines survive unchanged.
4900        let config = std::fs::read_to_string(staged.path().join("config.toml")).unwrap();
4901        assert!(
4902            config.starts_with("model_catalog_json = \"models.json\"\n"),
4903            "{config}"
4904        );
4905        assert!(config.ends_with(ZAI_CONFIG), "{config}");
4906        assert_eq!(
4907            mj_core::codex_provider::codex_provider(staged.path())
4908                .unwrap()
4909                .unwrap()
4910                .model_catalog_json
4911                .as_deref(),
4912            Some(Path::new("models.json")),
4913            "Codex reads the staged catalog as a top-level key"
4914        );
4915        // The staged copy is what the session runs from, so a session on a
4916        // local bare target must not use the profile home directly.
4917        assert!(super::super::requires_private_profile_home(&profile));
4918        assert!(
4919            !home.path().join("models.json").exists(),
4920            "the user's own profile home stays untouched"
4921        );
4922    }
4923
4924    #[test]
4925    fn a_failed_catalog_fetch_falls_back_to_the_last_cached_catalog() {
4926        let home = tempfile::tempdir().unwrap();
4927        let staged = tempfile::tempdir().unwrap();
4928        let store = tempfile::tempdir().unwrap();
4929        let profile = zai_profile(home.path());
4930        let cache = IsolatedCatalogCache(store.path().join("cache.sqlite3"));
4931
4932        stage_codex_catalog(
4933            "glm",
4934            &profile,
4935            staged.path(),
4936            &|_, _| Ok(ZAI_CATALOG.as_bytes().to_vec()),
4937            &cache,
4938        )
4939        .unwrap();
4940        std::fs::remove_file(staged.path().join("models.json")).unwrap();
4941
4942        stage_codex_catalog(
4943            "glm",
4944            &profile,
4945            staged.path(),
4946            &|_, _| bail!("the provider is unreachable"),
4947            &cache,
4948        )
4949        .expect("a provider outage must not block a launch");
4950        let catalog = mj_core::codex_catalog::parse(
4951            &std::fs::read(staged.path().join("models.json")).unwrap(),
4952        )
4953        .unwrap();
4954        assert_eq!(catalog.slugs(), ["glm-5.3", "glm-5.3-flash"]);
4955
4956        // With nothing cached for a different provider, the launch fails and
4957        // says which profile and URL could not be reached.
4958        let empty = tempfile::tempdir().unwrap();
4959        let error = stage_codex_catalog(
4960            "glm",
4961            &profile,
4962            staged.path(),
4963            &|_, _| bail!("the provider is unreachable"),
4964            &IsolatedCatalogCache(empty.path().join("empty.sqlite3")),
4965        )
4966        .expect_err("no catalog and no cache cannot launch")
4967        .to_string();
4968        assert!(error.contains("glm"), "{error}");
4969        assert!(error.contains("https://api.z.ai/api/v1/models"), "{error}");
4970    }
4971
4972    const DEEPSEEK_CONFIG: &str = "model = \"deepseek-v4-pro\"\n\
4973                                   model_provider = \"deepseek\"\n\
4974                                   \n\
4975                                   [model_providers.deepseek]\n\
4976                                   base_url = \"https://api.deepseek.com/v1\"\n\
4977                                   env_key = \"DEEPSEEK_API_KEY\"\n\
4978                                   wire_api = \"responses\"\n";
4979
4980    const DEEPSEEK_LIST: &str = r#"{"object":"list","data":[
4981        {"id":"deepseek-flash","object":"model","owned_by":"deepseek"},
4982        {"id":"deepseek-v4-pro","object":"model","owned_by":"deepseek"}
4983    ]}"#;
4984
4985    fn deepseek_profile(home: &Path) -> mj_core::config::HarnessProfile {
4986        std::fs::write(home.join("config.toml"), DEEPSEEK_CONFIG).unwrap();
4987        mj_core::config::HarnessProfile {
4988            enabled: true,
4989            kind: mj_core::config::HarnessKind::Codex,
4990            home: home.to_path_buf(),
4991            environment: BTreeMap::from([(
4992                "DEEPSEEK_API_KEY".to_owned(),
4993                "deepseek-key".to_owned(),
4994            )]),
4995            context_window_bytes: None,
4996            guardian_review_model: None,
4997        }
4998    }
4999
5000    fn stage_catalog_for(
5001        profile: &mj_core::config::HarnessProfile,
5002        body: &str,
5003        staged: &Path,
5004        store: &Path,
5005    ) -> Result<mj_core::codex_catalog::CodexCatalog> {
5006        stage_codex_catalog(
5007            "deepseek",
5008            profile,
5009            staged,
5010            &|_, _| Ok(body.as_bytes().to_vec()),
5011            &IsolatedCatalogCache(store.to_path_buf()),
5012        )?;
5013        mj_core::codex_catalog::parse(&std::fs::read(staged.join("models.json")).unwrap())
5014    }
5015
5016    #[test]
5017    fn a_plain_model_list_becomes_a_catalog_the_profiles_overrides_refine() {
5018        let home = tempfile::tempdir().unwrap();
5019        let staged = tempfile::tempdir().unwrap();
5020        let store = tempfile::tempdir().unwrap();
5021        let profile = deepseek_profile(home.path());
5022        std::fs::write(
5023            home.path().join("models.json"),
5024            r#"{"models":[
5025                {"slug":"deepseek-v4-pro","supported_reasoning_levels":["low","high"]},
5026                {"slug":"deepseek-preview","display_name":"DeepSeek Preview"}
5027            ]}"#,
5028        )
5029        .unwrap();
5030
5031        let catalog = stage_catalog_for(
5032            &profile,
5033            DEEPSEEK_LIST,
5034            staged.path(),
5035            &store.path().join("cache.sqlite3"),
5036        )
5037        .expect("an OpenAI-format model list stages a catalog");
5038
5039        assert_eq!(
5040            catalog.slugs(),
5041            ["deepseek-flash", "deepseek-v4-pro", "deepseek-preview"],
5042            "the override adds a model the provider's list omits"
5043        );
5044        assert_eq!(
5045            catalog.models[1]["supported_reasoning_levels"],
5046            serde_json::json!(["low", "high"]),
5047            "the override gives the translated entry its reasoning levels"
5048        );
5049        assert_eq!(
5050            catalog.models[0]["auto_review_model_override"],
5051            serde_json::Value::from("deepseek-flash"),
5052            "the newest flash model reviews by default"
5053        );
5054    }
5055
5056    #[test]
5057    fn the_guardian_review_setting_picks_which_model_reviews() {
5058        let home = tempfile::tempdir().unwrap();
5059        let store = tempfile::tempdir().unwrap();
5060        let mut profile = deepseek_profile(home.path());
5061        let cache = store.path().join("cache.sqlite3");
5062
5063        profile.guardian_review_model = Some("session".to_owned());
5064        let staged = tempfile::tempdir().unwrap();
5065        let catalog =
5066            stage_catalog_for(&profile, DEEPSEEK_LIST, staged.path(), &cache).expect("stage");
5067        assert!(
5068            catalog
5069                .models
5070                .iter()
5071                .all(|model| !model.contains_key("auto_review_model_override")),
5072            "with \"session\" Codex reviews with the session model, so nothing is stamped"
5073        );
5074
5075        profile.guardian_review_model = Some("deepseek-v4-pro".to_owned());
5076        let staged = tempfile::tempdir().unwrap();
5077        let catalog =
5078            stage_catalog_for(&profile, DEEPSEEK_LIST, staged.path(), &cache).expect("stage");
5079        for model in &catalog.models {
5080            assert_eq!(
5081                model["auto_review_model_override"],
5082                serde_json::Value::from("deepseek-v4-pro"),
5083                "a named slug reviews whichever model the session runs on"
5084            );
5085        }
5086
5087        profile.guardian_review_model = Some("deepseek-nonesuch".to_owned());
5088        let staged = tempfile::tempdir().unwrap();
5089        let error = stage_catalog_for(&profile, DEEPSEEK_LIST, staged.path(), &cache)
5090            .expect_err("a reviewer the provider does not serve cannot review")
5091            .to_string();
5092        assert!(error.contains("deepseek-nonesuch"), "{error}");
5093        assert!(error.contains("deepseek"), "{error}");
5094        assert!(error.contains("deepseek-flash"), "{error}");
5095        assert!(
5096            !staged.path().join("models.json").exists(),
5097            "a rejected reviewer stages no catalog at all"
5098        );
5099    }
5100
5101    #[test]
5102    fn a_native_codex_profile_gets_no_generated_catalog() {
5103        let home = tempfile::tempdir().unwrap();
5104        std::fs::write(home.path().join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
5105        let staged = tempfile::tempdir().unwrap();
5106        let store = tempfile::tempdir().unwrap();
5107        let profile = mj_core::config::HarnessProfile {
5108            enabled: true,
5109            kind: mj_core::config::HarnessKind::Codex,
5110            home: home.path().to_path_buf(),
5111            environment: BTreeMap::new(),
5112            context_window_bytes: None,
5113            guardian_review_model: None,
5114        };
5115
5116        stage_profile(&profile, staged.path()).unwrap();
5117        stage_codex_catalog(
5118            "work",
5119            &profile,
5120            staged.path(),
5121            &|_, _| panic!("a profile with no custom provider must not fetch a catalog"),
5122            &IsolatedCatalogCache(store.path().join("cache.sqlite3")),
5123        )
5124        .unwrap();
5125
5126        assert!(!staged.path().join("models.json").exists());
5127        assert_eq!(
5128            std::fs::read_to_string(staged.path().join("config.toml")).unwrap(),
5129            "model = \"gpt-5.5\"\n"
5130        );
5131        assert!(!super::super::requires_private_profile_home(&profile));
5132    }
5133
5134    #[test]
5135    fn stage_grok_profile_copies_authentication_and_agent_identity() {
5136        let home = tempfile::tempdir().unwrap();
5137        std::fs::write(
5138            home.path().join("auth.json"),
5139            "{\"https://auth.x.ai::1\":{}}",
5140        )
5141        .unwrap();
5142        std::fs::write(home.path().join("agent_id"), "stable-agent-id").unwrap();
5143        std::fs::write(home.path().join("config.toml"), "model = \"grok-4.6\"\n").unwrap();
5144        // Native session storage is checkpointed, never staged.
5145        std::fs::create_dir(home.path().join("sessions")).unwrap();
5146        std::fs::write(home.path().join("sessions/session_search.sqlite"), "x").unwrap();
5147        let staged = tempfile::tempdir().unwrap();
5148        let profile = mj_core::config::HarnessProfile {
5149            enabled: true,
5150            kind: mj_core::config::HarnessKind::Grok,
5151            home: home.path().to_path_buf(),
5152            environment: BTreeMap::new(),
5153            context_window_bytes: None,
5154            guardian_review_model: None,
5155        };
5156
5157        stage_profile(&profile, staged.path()).unwrap();
5158
5159        assert_eq!(
5160            std::fs::read_to_string(staged.path().join("agent_id")).unwrap(),
5161            "stable-agent-id"
5162        );
5163        assert!(staged.path().join("auth.json").is_file());
5164        assert!(staged.path().join("config.toml").is_file());
5165        assert!(!staged.path().join("sessions").exists());
5166    }
5167    #[test]
5168    fn stage_claude_profile_preserves_rollout_identity() {
5169        let home = tempfile::tempdir().unwrap();
5170        let identity = r#"{
5171                "machineID": "stable-machine",
5172                "userID": "stable-user",
5173                "cachedGrowthBookFeatures": {
5174                    "tengu_velvet_mallet_fable_5": true
5175                }
5176            }"#;
5177        std::fs::write(home.path().join(".claude.json"), identity).unwrap();
5178        let staged = tempfile::tempdir().unwrap();
5179        let profile = mj_core::config::HarnessProfile {
5180            enabled: true,
5181            kind: mj_core::config::HarnessKind::Claude,
5182            home: home.path().to_path_buf(),
5183            environment: BTreeMap::new(),
5184            context_window_bytes: None,
5185            guardian_review_model: None,
5186        };
5187
5188        stage_profile(&profile, staged.path()).unwrap();
5189
5190        assert_eq!(
5191            std::fs::read_to_string(staged.path().join(".claude.json")).unwrap(),
5192            identity
5193        );
5194    }
5195
5196    #[cfg(unix)]
5197    #[test]
5198    fn stage_claude_profile_follows_symlinked_entries() {
5199        let outside = tempfile::tempdir().unwrap();
5200        std::fs::write(outside.path().join("settings.json"), "{\"model\":\"opus\"}").unwrap();
5201        std::fs::write(outside.path().join("CLAUDE.md"), "# linked instructions\n").unwrap();
5202        let skills = outside.path().join("skills");
5203        std::fs::create_dir_all(skills.join("review")).unwrap();
5204        std::fs::write(skills.join("review/SKILL.md"), "review skill\n").unwrap();
5205        // A dangling link inside a copied tree must not fail staging.
5206        std::os::unix::fs::symlink(outside.path().join("missing"), skills.join("dangling.md"))
5207            .unwrap();
5208
5209        let home = tempfile::tempdir().unwrap();
5210        std::os::unix::fs::symlink(
5211            outside.path().join("settings.json"),
5212            home.path().join("settings.json"),
5213        )
5214        .unwrap();
5215        std::os::unix::fs::symlink(
5216            outside.path().join("CLAUDE.md"),
5217            home.path().join("CLAUDE.md"),
5218        )
5219        .unwrap();
5220        std::os::unix::fs::symlink(&skills, home.path().join("skills")).unwrap();
5221
5222        let staged = tempfile::tempdir().unwrap();
5223        let profile = mj_core::config::HarnessProfile {
5224            enabled: true,
5225            kind: mj_core::config::HarnessKind::Claude,
5226            home: home.path().to_path_buf(),
5227            environment: BTreeMap::new(),
5228            context_window_bytes: None,
5229            guardian_review_model: None,
5230        };
5231
5232        stage_profile(&profile, staged.path()).unwrap();
5233
5234        for (relative, contents) in [
5235            ("settings.json", "{\"model\":\"opus\"}"),
5236            ("CLAUDE.md", "# linked instructions\n"),
5237            ("skills/review/SKILL.md", "review skill\n"),
5238        ] {
5239            let path = staged.path().join(relative);
5240            let metadata = std::fs::symlink_metadata(&path).unwrap();
5241            assert!(
5242                metadata.file_type().is_file(),
5243                "{relative} should be staged as a regular file"
5244            );
5245            assert_eq!(std::fs::read_to_string(&path).unwrap(), contents);
5246        }
5247        assert!(!staged.path().join("skills/dangling.md").exists());
5248    }
5249
5250    #[cfg(unix)]
5251    #[test]
5252    fn stage_claude_profile_skips_dangling_allowlist_symlinks() {
5253        let outside = tempfile::tempdir().unwrap();
5254        let home = tempfile::tempdir().unwrap();
5255        std::os::unix::fs::symlink(
5256            outside.path().join("missing"),
5257            home.path().join("CLAUDE.md"),
5258        )
5259        .unwrap();
5260        std::fs::write(home.path().join("settings.json"), "{}").unwrap();
5261        let staged = tempfile::tempdir().unwrap();
5262        let profile = mj_core::config::HarnessProfile {
5263            enabled: true,
5264            kind: mj_core::config::HarnessKind::Claude,
5265            home: home.path().to_path_buf(),
5266            environment: BTreeMap::new(),
5267            context_window_bytes: None,
5268            guardian_review_model: None,
5269        };
5270
5271        stage_profile(&profile, staged.path()).unwrap();
5272
5273        assert!(!staged.path().join("CLAUDE.md").exists());
5274        assert!(staged.path().join("settings.json").is_file());
5275    }
5276
5277    fn staged_muse_settings(body: &str) -> (tempfile::TempDir, PathBuf) {
5278        let staged = tempfile::tempdir().unwrap();
5279        let path = staged.path().join("settings.json");
5280        std::fs::write(&path, body).unwrap();
5281        (staged, path)
5282    }
5283
5284    fn stage_muse_settings(profile_stage: &Path) {
5285        apply_staged_execution_setting(
5286            HarnessKind::Muse,
5287            ExecutionPolicy::Unconstrained,
5288            profile_stage,
5289        )
5290        .unwrap();
5291    }
5292
5293    #[test]
5294    fn muse_staged_settings_select_the_unrestricted_profile() {
5295        let (staged, path) = staged_muse_settings(
5296            r#"{
5297                "schema_version": 1,
5298                "provider": "anthropic",
5299                "model": "muse-1",
5300                "tui": {"theme": "dark"},
5301                "permissions": {"schema_version": 1, "default_profile": ":auto-review"}
5302            }"#,
5303        );
5304
5305        stage_muse_settings(staged.path());
5306
5307        let document: serde_json::Value =
5308            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
5309        assert_eq!(document["provider"], "anthropic");
5310        assert_eq!(document["model"], "muse-1");
5311        assert_eq!(document["tui"]["theme"], "dark");
5312        assert_eq!(document["schema_version"], 1);
5313        assert_eq!(document["permissions"]["schema_version"], 1);
5314        assert_eq!(document["permissions"]["default_profile"], ":unrestricted");
5315    }
5316
5317    #[test]
5318    fn muse_staged_settings_are_created_when_absent() {
5319        let staged = tempfile::tempdir().unwrap();
5320
5321        stage_muse_settings(staged.path());
5322
5323        let body = std::fs::read_to_string(staged.path().join("settings.json")).unwrap();
5324        assert!(body.ends_with('\n'));
5325        assert_eq!(
5326            serde_json::from_str::<serde_json::Value>(&body).unwrap(),
5327            serde_json::json!({
5328                "schema_version": 1,
5329                "permissions": {"schema_version": 1, "default_profile": ":unrestricted"}
5330            })
5331        );
5332    }
5333
5334    #[test]
5335    fn a_harness_without_a_staged_setting_leaves_the_profile_untouched() {
5336        let source = r#"{"schema_version": 1, "permissions": {"default_profile": ":ask-me"}}"#;
5337
5338        for (kind, policy) in [
5339            (HarnessKind::Claude, ExecutionPolicy::Unconstrained),
5340            (HarnessKind::Muse, ExecutionPolicy::ConfiguredApprovals),
5341        ] {
5342            let (staged, path) = staged_muse_settings(source);
5343
5344            apply_staged_execution_setting(kind, policy, staged.path()).unwrap();
5345
5346            assert_eq!(std::fs::read_to_string(&path).unwrap(), source, "{kind:?}");
5347        }
5348    }
5349
5350    #[test]
5351    fn muse_settings_that_are_not_an_object_report_the_staged_file() {
5352        let (staged, path) = staged_muse_settings("[]");
5353
5354        let error = apply_staged_execution_setting(
5355            HarnessKind::Muse,
5356            ExecutionPolicy::Unconstrained,
5357            staged.path(),
5358        )
5359        .unwrap_err();
5360
5361        assert!(
5362            format!("{error:#}").contains(&path.display().to_string()),
5363            "error should name the staged file: {error:#}"
5364        );
5365    }
5366
5367    #[test]
5368    fn a_custom_provider_session_carries_its_key_and_runs_from_a_private_home() {
5369        let project = tempfile::tempdir().unwrap();
5370        let home = tempfile::tempdir().unwrap();
5371        let profile = zai_profile(home.path());
5372        let mut session = crate::controller::test_support::checkpoint_test_session("s-glm");
5373        session.harness_kind = HarnessKind::Codex;
5374        session.last_profile = "glm".into();
5375        session.target_template_id = "localhost".into();
5376        session.project_directory = Some(project.path().to_path_buf());
5377        session.target = Some(mj_core::state::TargetLocator::LocalBare {
5378            worker_root: "/home/me/.local/share/hel/worker".into(),
5379        });
5380
5381        let (launch, _, target_home) = worker_launch_config(
5382            &session,
5383            &profile,
5384            None,
5385            &targets::TargetLocator::LocalBare {
5386                worker_root: "/home/me/.local/share/hel/worker".into(),
5387            },
5388            &session.id,
5389            &session.id,
5390            &mj_core::config::TargetTemplate::LocalBare,
5391        )
5392        .unwrap();
5393
5394        assert_eq!(launch.environment["ZAI_API_KEY"], "coding-plan-key");
5395        assert_eq!(launch.environment["CODEX_HOME"], target_home);
5396        assert_eq!(
5397            target_home, "/home/me/.local/share/hel/worker/profile",
5398            "the session runs from the staged copy, not the user's profile home"
5399        );
5400        assert_eq!(
5401            launch.authentication_marker.as_deref(),
5402            Some("config.toml"),
5403            "the worker checks the Codex configuration, not a ChatGPT auth file"
5404        );
5405        // Guardian still applies: a raw local target keeps configured approvals.
5406        assert_eq!(
5407            launch.execution_policy,
5408            ExecutionPolicy::ConfiguredApprovals
5409        );
5410        assert_eq!(launch.environment["INITIAL_AGENT_MODE"], "agent");
5411        assert!(profile.supports_guardian_approvals());
5412    }
5413
5414    /// Muse has no guardian mode, so even a raw local target launches it
5415    /// unconstrained.
5416    #[test]
5417    fn raw_local_muse_launches_unconstrained() {
5418        let project = tempfile::tempdir().unwrap();
5419        let mut session = crate::controller::test_support::checkpoint_test_session("s-muse");
5420        session.harness_kind = HarnessKind::Muse;
5421        session.last_profile = "muse".into();
5422        session.target_template_id = "localhost".into();
5423        session.project_directory = Some(project.path().to_path_buf());
5424        session.target = Some(mj_core::state::TargetLocator::LocalBare {
5425            worker_root: "/home/me/.local/share/hel/worker".into(),
5426        });
5427        let profile = mj_core::config::HarnessProfile {
5428            enabled: true,
5429            kind: HarnessKind::Muse,
5430            home: PathBuf::from("/profiles/muse"),
5431            environment: BTreeMap::new(),
5432            context_window_bytes: None,
5433            guardian_review_model: None,
5434        };
5435
5436        let (launch, _, _) = worker_launch_config(
5437            &session,
5438            &profile,
5439            None,
5440            &targets::TargetLocator::LocalBare {
5441                worker_root: "/home/me/.local/share/hel/worker".into(),
5442            },
5443            &session.id,
5444            &session.id,
5445            &mj_core::config::TargetTemplate::LocalBare,
5446        )
5447        .unwrap();
5448
5449        assert_eq!(launch.execution_policy, ExecutionPolicy::Unconstrained);
5450        assert_eq!(launch.environment["MUSE_APPROVAL_MODE"], "allowAll");
5451        assert_eq!(launch.environment["MUSE_SERVE_ARGS"], "--disable-sandbox");
5452    }
5453
5454    #[test]
5455    fn stage_kimi_profile_preserves_device_identity() {
5456        let home = tempfile::tempdir().unwrap();
5457        std::fs::write(home.path().join("config.toml"), "default_model = \"k3\"\n").unwrap();
5458        std::fs::write(home.path().join("device_id"), "stable-device-id").unwrap();
5459        std::fs::create_dir(home.path().join("credentials")).unwrap();
5460        std::fs::write(
5461            home.path().join("credentials/kimi-code.json"),
5462            "{\"access_token\":\"secret\"}",
5463        )
5464        .unwrap();
5465        let staged = tempfile::tempdir().unwrap();
5466        let profile = mj_core::config::HarnessProfile {
5467            enabled: true,
5468            kind: mj_core::config::HarnessKind::Kimi,
5469            home: home.path().to_path_buf(),
5470            environment: BTreeMap::new(),
5471            context_window_bytes: None,
5472            guardian_review_model: None,
5473        };
5474
5475        stage_profile(&profile, staged.path()).unwrap();
5476
5477        assert_eq!(
5478            std::fs::read_to_string(staged.path().join("device_id")).unwrap(),
5479            "stable-device-id"
5480        );
5481        assert!(staged.path().join("credentials/kimi-code.json").is_file());
5482    }
5483    #[test]
5484    fn staged_kimi_profile_binds_project_memory_to_the_target_runtime() {
5485        let home = tempfile::tempdir().unwrap();
5486        let original = serde_json::json!({
5487            "mcpServers": {
5488                "user-server": {
5489                    "command": "user-mcp",
5490                    "args": ["serve"]
5491                }
5492            },
5493            "userSetting": true
5494        });
5495        let original_body = serde_json::to_vec_pretty(&original).unwrap();
5496        std::fs::write(home.path().join("mcp.json"), &original_body).unwrap();
5497        let staged = tempfile::tempdir().unwrap();
5498        let profile = mj_core::config::HarnessProfile {
5499            enabled: true,
5500            kind: mj_core::config::HarnessKind::Kimi,
5501            home: home.path().to_path_buf(),
5502            environment: BTreeMap::new(),
5503            context_window_bytes: None,
5504            guardian_review_model: None,
5505        };
5506        stage_profile(&profile, staged.path()).unwrap();
5507        let memory = ProjectMemoryLaunchConfig {
5508            project_key: "project".into(),
5509            root: "/var/lib/hel/profiles/session/projects/project/memory".into(),
5510            baseline_root: PathBuf::new(),
5511            repository_roots: BTreeMap::new(),
5512            mcp_delivery: ProjectMemoryMcpDelivery::HarnessProfile,
5513        };
5514
5515        configure_kimi_project_memory_mcp(staged.path(), "/var/lib/hel/workers/session", &memory)
5516            .unwrap();
5517
5518        let configured: serde_json::Value =
5519            serde_json::from_slice(&std::fs::read(staged.path().join("mcp.json")).unwrap())
5520                .unwrap();
5521        assert_eq!(configured["userSetting"], true);
5522        assert_eq!(
5523            configured["mcpServers"]["user-server"]["command"],
5524            "user-mcp"
5525        );
5526        assert_eq!(
5527            configured["mcpServers"]["mj-memory"],
5528            serde_json::json!({
5529                "transport": "stdio",
5530                "command": "/var/lib/hel/workers/session/hel",
5531                "args": [
5532                    "worker",
5533                    "memory-mcp",
5534                    "--root",
5535                    "/var/lib/hel/profiles/session/projects/project/memory"
5536                ],
5537                "runtime_id": "local"
5538            })
5539        );
5540        assert_eq!(
5541            std::fs::read(home.path().join("mcp.json")).unwrap(),
5542            original_body,
5543            "the controller-side Kimi profile must remain unchanged"
5544        );
5545    }
5546
5547    #[test]
5548    fn staged_kimi_project_memory_resolves_ssh_paths_from_target_home() {
5549        let staged = tempfile::tempdir().unwrap();
5550        let memory = ProjectMemoryLaunchConfig {
5551            project_key: "project".into(),
5552            root: ".local/share/hel/profiles/session/projects/project/memory".into(),
5553            baseline_root: PathBuf::new(),
5554            repository_roots: BTreeMap::new(),
5555            mcp_delivery: ProjectMemoryMcpDelivery::HarnessProfile,
5556        };
5557
5558        configure_kimi_project_memory_mcp(
5559            staged.path(),
5560            ".local/share/hel/workers/session",
5561            &memory,
5562        )
5563        .unwrap();
5564
5565        let configured: serde_json::Value =
5566            serde_json::from_slice(&std::fs::read(staged.path().join("mcp.json")).unwrap())
5567                .unwrap();
5568        let server = &configured["mcpServers"]["mj-memory"];
5569        assert_eq!(server["command"], "sh");
5570        assert_eq!(server["runtime_id"], "local");
5571        assert_eq!(
5572            server["args"],
5573            serde_json::json!([
5574                "-c",
5575                "exec \"$HOME/$1\" worker memory-mcp --root \"$HOME/$2\"",
5576                "mj-memory",
5577                ".local/share/hel/workers/session/hel",
5578                ".local/share/hel/profiles/session/projects/project/memory"
5579            ])
5580        );
5581    }
5582    #[test]
5583    fn disposable_container_guidance_reaches_each_harness_without_touching_home() {
5584        let target = targets::TargetLocator::LocalPodman {
5585            container_id: "container".into(),
5586            workspace_storage: Default::default(),
5587        };
5588        for (kind, instructions) in [
5589            (mj_core::config::HarnessKind::Codex, "AGENTS.md"),
5590            (mj_core::config::HarnessKind::Claude, "CLAUDE.md"),
5591            (mj_core::config::HarnessKind::Kimi, "AGENTS.md"),
5592            (mj_core::config::HarnessKind::Grok, "AGENTS.md"),
5593            (mj_core::config::HarnessKind::Muse, "AGENTS.md"),
5594        ] {
5595            let home = tempfile::tempdir().unwrap();
5596            let original = "# Controller instructions\n\nKeep this source unchanged.\n";
5597            let source_instructions = home.path().join(instructions);
5598            std::fs::write(&source_instructions, original).unwrap();
5599            let staged = tempfile::tempdir().unwrap();
5600            let profile = mj_core::config::HarnessProfile {
5601                enabled: true,
5602                kind,
5603                home: home.path().to_path_buf(),
5604                environment: std::collections::BTreeMap::new(),
5605                context_window_bytes: None,
5606                guardian_review_model: None,
5607            };
5608
5609            stage_profile(&profile, staged.path()).unwrap();
5610            append_hel_target_environment(kind, staged.path(), &target).unwrap();
5611
5612            let guidance = std::fs::read_to_string(staged.path().join(instructions)).unwrap();
5613            assert_eq!(
5614                guidance,
5615                format!("{original}\n{MJ_CONTAINER_ENVIRONMENT}"),
5616                "{instructions} receives the section in the staged profile"
5617            );
5618            assert!(guidance.contains("## Mjolnir disposable environment"));
5619            assert!(!guidance.contains("## Hel disposable environment"));
5620            assert_eq!(
5621                std::fs::read_to_string(source_instructions).unwrap(),
5622                original,
5623                "{instructions} in the controller-side home stays untouched"
5624            );
5625        }
5626    }
5627    #[test]
5628    fn kimi_guidance_uses_agents_md_without_mutating_the_system_override() {
5629        let home = tempfile::tempdir().unwrap();
5630        let system_override = "# Custom Kimi system prompt\n";
5631        std::fs::write(home.path().join("SYSTEM.md"), system_override).unwrap();
5632        let staged = tempfile::tempdir().unwrap();
5633        let profile = mj_core::config::HarnessProfile {
5634            enabled: true,
5635            kind: mj_core::config::HarnessKind::Kimi,
5636            home: home.path().to_path_buf(),
5637            environment: std::collections::BTreeMap::new(),
5638            context_window_bytes: None,
5639            guardian_review_model: None,
5640        };
5641
5642        stage_profile(&profile, staged.path()).unwrap();
5643        append_hel_target_environment(
5644            profile.kind,
5645            staged.path(),
5646            &targets::TargetLocator::LocalPodman {
5647                container_id: "container".into(),
5648                workspace_storage: Default::default(),
5649            },
5650        )
5651        .unwrap();
5652
5653        assert_eq!(
5654            std::fs::read_to_string(staged.path().join("AGENTS.md")).unwrap(),
5655            MJ_CONTAINER_ENVIRONMENT
5656        );
5657        assert_eq!(
5658            std::fs::read_to_string(staged.path().join("SYSTEM.md")).unwrap(),
5659            system_override
5660        );
5661        assert!(!home.path().join("AGENTS.md").exists());
5662        assert_eq!(
5663            std::fs::read_to_string(home.path().join("SYSTEM.md")).unwrap(),
5664            system_override
5665        );
5666    }
5667
5668    #[test]
5669    fn ec2_guidance_names_its_real_workspace_and_ssh_bare_gets_none() {
5670        let ec2 = tempfile::tempdir().unwrap();
5671        append_hel_target_environment(
5672            mj_core::config::HarnessKind::Codex,
5673            ec2.path(),
5674            &targets::TargetLocator::AwsEc2 {
5675                profile: "profile".into(),
5676                region: "region".into(),
5677                instance_id: "instance".into(),
5678                ssh: targets::SshTarget {
5679                    destination: "host".into(),
5680                    ssh_args: Vec::new(),
5681                },
5682                workspace: ".local/share/hel/workspaces/session".into(),
5683            },
5684        )
5685        .unwrap();
5686        let guidance = std::fs::read_to_string(ec2.path().join("AGENTS.md")).unwrap();
5687        assert_eq!(
5688            guidance,
5689            "## Mjolnir disposable environment\n\nThis session runs on a disposable Mjolnir EC2 instance. When the session closes, Mjolnir checkpoints everything in project workspace directories under `$HOME/.local/share/hel/workspaces/session`, including committed work, staged and unstaged changes, and untracked files. Mjolnir then terminates the instance.\n\nEverything outside `$HOME/.local/share/hel/workspaces/session`, including installed packages, the rest of `$HOME`, and `/tmp`, is ephemeral and will be lost. Keep durable results in the workspace or push them to a remote.\n\nNew workspaces start on their own session branch from the default network fetch remote’s default branch. Local unpublished commits and uncommitted files are not copied. Use normal git push to publish the current branch to the configured network push destination. Closing saves a checkpoint; it does not publish commits or update the original local checkout. Resumed sessions restore their saved work.\n"
5690        );
5691        assert!(!guidance.contains("## Hel disposable environment"));
5692
5693        let ssh_bare = tempfile::tempdir().unwrap();
5694        append_hel_target_environment(
5695            mj_core::config::HarnessKind::Codex,
5696            ssh_bare.path(),
5697            &targets::TargetLocator::SshBare {
5698                worker_id: None,
5699                ssh: targets::SshTarget {
5700                    destination: "host".into(),
5701                    ssh_args: Vec::new(),
5702                },
5703                workspace: ".local/share/hel/workspaces/session".into(),
5704            },
5705        )
5706        .unwrap();
5707        assert!(!ssh_bare.path().join("AGENTS.md").exists());
5708    }
5709
5710    #[test]
5711    fn project_memory_replicas_are_session_private() {
5712        let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
5713        assert_eq!(
5714            project_memory_replica_slug(key, "session-a"),
5715            "hel-0123456789abcdef-session-a"
5716        );
5717        assert_ne!(
5718            project_memory_replica_slug(key, "session-a"),
5719            project_memory_replica_slug(key, "session-b")
5720        );
5721    }
5722
5723    /// Returns a fixed digest line for every command and records what it ran,
5724    /// so a remote refresh can be driven without a real ssh host.
5725    struct DigestExecutor {
5726        installed_line: String,
5727        commands: RefCell<Vec<CommandSpec>>,
5728    }
5729
5730    impl CommandExecutor for DigestExecutor {
5731        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
5732            self.commands.borrow_mut().push(command.clone());
5733            Ok(CommandOutput {
5734                status: 0,
5735                stdout: self.installed_line.clone().into_bytes(),
5736                stderr: Vec::new(),
5737            })
5738        }
5739    }
5740
5741    // The SshBare worker_root guard requires the workspace to end in the exact
5742    // session ID, so build the locator around the session under test.
5743    fn ssh_bare_locator(session_id: &str) -> targets::TargetLocator {
5744        targets::TargetLocator::SshBare {
5745            worker_id: None,
5746            ssh: SshTarget {
5747                destination: "user@host.test".into(),
5748                ssh_args: Vec::new(),
5749            },
5750            workspace: format!("/srv/mj/{session_id}"),
5751        }
5752    }
5753
5754    #[test]
5755    fn remote_upgrade_prepares_managed_harness_without_touching_running_worker() {
5756        let session = "session-remote";
5757        let executor = DigestExecutor {
5758            installed_line: String::new(),
5759            commands: RefCell::new(Vec::new()),
5760        };
5761        let launch = WorkerLaunchConfig {
5762            subagent_tools: false,
5763            goal_resume_request: Default::default(),
5764            target_environment: Default::default(),
5765            run_mode: Default::default(),
5766            session_id: session.into(),
5767            harness: HarnessKind::Codex,
5768            authentication_marker: None,
5769            bridge_command: "ignored".into(),
5770            bridge_args: Vec::new(),
5771            harness_runtime: HarnessRuntimePolicy::Managed,
5772            environment: BTreeMap::new(),
5773            cwd: "/srv/mj/session-remote/project".into(),
5774            additional_directories: Vec::new(),
5775            native_session_id: None,
5776            project_memory: None,
5777            execution_policy: ExecutionPolicy::ConfiguredApprovals,
5778        };
5779
5780        prepare_managed_harness_for_upgrade(
5781            &executor,
5782            &ssh_bare_locator(session),
5783            session,
5784            Path::new("/controller/hel"),
5785            &launch,
5786        )
5787        .unwrap();
5788
5789        let commands = executor.commands.borrow();
5790        let purposes = commands
5791            .iter()
5792            .map(|command| command.purpose.as_str())
5793            .collect::<Vec<_>>();
5794        assert_eq!(
5795            purposes,
5796            vec![
5797                "clear managed harness preparation staging",
5798                "create managed harness preparation staging",
5799                "stage current worker for managed harness preparation",
5800                "stage managed harness launch configuration",
5801                "make managed harness preparation worker executable",
5802                "prepare exact managed harness",
5803                "remove managed harness preparation staging",
5804            ]
5805        );
5806        assert!(commands.iter().all(|command| {
5807            !command.purpose.contains("stop Mjolnir worker")
5808                && !command.purpose.contains("start Mjolnir worker")
5809                && !command
5810                    .purpose
5811                    .contains("install the current Mjolnir worker binary")
5812        }));
5813        let prepare = commands
5814            .iter()
5815            .find(|command| command.purpose == "prepare exact managed harness")
5816            .unwrap();
5817        let rendered = format!("{} {}", prepare.program, prepare.args.join(" "));
5818        assert!(rendered.contains("worker' 'prepare-harness' '--config'"));
5819    }
5820
5821    #[test]
5822    fn local_upgrade_preflight_uses_current_binary_and_preserves_launch_policy() {
5823        struct ConfigRecordingExecutor {
5824            command: RefCell<Option<CommandSpec>>,
5825            launch: RefCell<Option<WorkerLaunchConfig>>,
5826        }
5827
5828        impl CommandExecutor for ConfigRecordingExecutor {
5829            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
5830                let config_path = command
5831                    .args
5832                    .get(3)
5833                    .context("local prepare command did not include its config path")?;
5834                *self.command.borrow_mut() = Some(command.clone());
5835                *self.launch.borrow_mut() = Some(WorkerLaunchConfig::read(Path::new(config_path))?);
5836                Ok(CommandOutput {
5837                    status: 0,
5838                    stdout: Vec::new(),
5839                    stderr: Vec::new(),
5840                })
5841            }
5842        }
5843
5844        struct FailingExecutor {
5845            purposes: RefCell<Vec<String>>,
5846        }
5847
5848        impl CommandExecutor for FailingExecutor {
5849            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
5850                self.purposes.borrow_mut().push(command.purpose.clone());
5851                Err(anyhow::anyhow!("managed harness installation failed"))
5852            }
5853        }
5854
5855        let executor = ConfigRecordingExecutor {
5856            command: RefCell::new(None),
5857            launch: RefCell::new(None),
5858        };
5859        let launch = WorkerLaunchConfig {
5860            subagent_tools: false,
5861            goal_resume_request: Default::default(),
5862            target_environment: Default::default(),
5863            run_mode: Default::default(),
5864            session_id: "session-local".into(),
5865            harness: HarnessKind::Codex,
5866            authentication_marker: None,
5867            bridge_command: "ignored".into(),
5868            bridge_args: Vec::new(),
5869            harness_runtime: HarnessRuntimePolicy::Managed,
5870            environment: BTreeMap::from([("CODEX_HOME".into(), "/configured/profile/home".into())]),
5871            cwd: "/workspace/project".into(),
5872            additional_directories: Vec::new(),
5873            native_session_id: None,
5874            project_memory: None,
5875            execution_policy: ExecutionPolicy::ConfiguredApprovals,
5876        };
5877        let locator = targets::TargetLocator::LocalBare {
5878            worker_root: "/worker/session-local".into(),
5879        };
5880
5881        prepare_managed_harness_for_upgrade(
5882            &executor,
5883            &locator,
5884            "session-local",
5885            Path::new("/controller/hel"),
5886            &launch,
5887        )
5888        .unwrap();
5889
5890        {
5891            let command = executor.command.borrow();
5892            let command = command.as_ref().unwrap();
5893            assert_eq!(command.purpose, "prepare exact managed harness");
5894            assert_eq!(command.program, "/controller/hel");
5895            assert_eq!(
5896                &command.args[..3],
5897                ["worker", "prepare-harness", "--config"]
5898            );
5899            assert!(!command.args[3].contains("/worker/session-local"));
5900        }
5901
5902        let prepared = executor.launch.borrow();
5903        let prepared = prepared.as_ref().unwrap();
5904        assert_eq!(
5905            prepared.environment.get("CODEX_HOME").map(String::as_str),
5906            Some("/configured/profile/home")
5907        );
5908        assert_eq!(
5909            prepared.execution_policy,
5910            ExecutionPolicy::ConfiguredApprovals
5911        );
5912
5913        let failing = FailingExecutor {
5914            purposes: RefCell::new(Vec::new()),
5915        };
5916        let error = prepare_managed_harness_for_upgrade(
5917            &failing,
5918            &locator,
5919            "session-local",
5920            Path::new("/controller/hel"),
5921            &launch,
5922        )
5923        .unwrap_err();
5924        assert!(
5925            error
5926                .to_string()
5927                .contains("managed harness installation failed")
5928        );
5929        assert_eq!(
5930            failing.purposes.borrow().as_slice(),
5931            ["prepare exact managed harness"]
5932        );
5933    }
5934
5935    #[test]
5936    fn initial_bare_provision_prepares_the_harness_from_installed_files() {
5937        let session = "session-remote";
5938        let executor = DigestExecutor {
5939            installed_line: String::new(),
5940            commands: RefCell::new(Vec::new()),
5941        };
5942        let mut launch = WorkerLaunchConfig {
5943            subagent_tools: false,
5944            goal_resume_request: Default::default(),
5945            target_environment: Default::default(),
5946            run_mode: Default::default(),
5947            session_id: session.into(),
5948            harness: HarnessKind::Kimi,
5949            authentication_marker: None,
5950            bridge_command: "ignored".into(),
5951            bridge_args: Vec::new(),
5952            harness_runtime: HarnessRuntimePolicy::Managed,
5953            environment: BTreeMap::new(),
5954            cwd: "/srv/mj/session-remote/project".into(),
5955            additional_directories: Vec::new(),
5956            native_session_id: None,
5957            project_memory: None,
5958            execution_policy: ExecutionPolicy::ConfiguredApprovals,
5959        };
5960
5961        let locator = ssh_bare_locator(session);
5962        prepare_installed_managed_harness(&executor, &locator, "/worker/root", &launch).unwrap();
5963        let commands = executor.commands.borrow();
5964        assert_eq!(commands.len(), 1);
5965        assert_eq!(
5966            commands[0].purpose,
5967            "prepare exact managed harness before worker startup"
5968        );
5969        let rendered = format!("{} {}", commands[0].program, commands[0].args.join(" "));
5970        assert!(rendered.contains("'/worker/root/hel' 'worker' 'prepare-harness'"));
5971        drop(commands);
5972
5973        let local = targets::TargetLocator::LocalBare {
5974            worker_root: "/worker/session-remote".into(),
5975        };
5976        prepare_installed_managed_harness(&executor, &local, "/worker/session-remote", &launch)
5977            .unwrap();
5978        let commands = executor.commands.borrow();
5979        assert_eq!(commands.len(), 2);
5980        assert_eq!(commands[1].program, "/worker/session-remote/hel");
5981        assert_eq!(
5982            commands[1].args,
5983            vec![
5984                "worker".to_owned(),
5985                "prepare-harness".to_owned(),
5986                "--config".to_owned(),
5987                "/worker/session-remote/launch.json".to_owned(),
5988            ]
5989        );
5990        drop(commands);
5991
5992        launch.harness_runtime = HarnessRuntimePolicy::Ambient;
5993        prepare_installed_managed_harness(&executor, &locator, "/worker/root", &launch).unwrap();
5994        assert_eq!(executor.commands.borrow().len(), 2);
5995    }
5996
5997    #[test]
5998    fn a_remote_worker_with_a_mismatched_binary_is_replaced_before_restart() {
5999        let directory = tempfile::tempdir().unwrap();
6000        let source = directory.path().join("worker");
6001        std::fs::write(&source, b"fresh musl worker").unwrap();
6002        let executor = DigestExecutor {
6003            installed_line: format!("{}  /root/hel\n", "0".repeat(64)),
6004            commands: RefCell::new(Vec::new()),
6005        };
6006        let replaced = replace_remote_worker_binary_if_stale(
6007            &executor,
6008            &ssh_bare_locator("session-remote"),
6009            "session-remote",
6010            &CommandSpec::new("true", Vec::<String>::new()),
6011            &source,
6012        )
6013        .unwrap();
6014        assert!(replaced, "a stale remote binary must be replaced");
6015        assert!(
6016            executor.commands.borrow().len() > 1,
6017            "the digest probe must be followed by replacement commands"
6018        );
6019    }
6020
6021    #[test]
6022    fn a_remote_worker_already_current_is_restarted_without_recopying() {
6023        let directory = tempfile::tempdir().unwrap();
6024        let source = directory.path().join("worker");
6025        std::fs::write(&source, b"fresh musl worker").unwrap();
6026        let current = mj_core::worker_launch::worker_executable_digest(&source).unwrap();
6027        let executor = DigestExecutor {
6028            installed_line: format!("{current}  /root/hel\n"),
6029            commands: RefCell::new(Vec::new()),
6030        };
6031        let replaced = replace_remote_worker_binary_if_stale(
6032            &executor,
6033            &ssh_bare_locator("session-remote"),
6034            "session-remote",
6035            &CommandSpec::new("true", Vec::<String>::new()),
6036            &source,
6037        )
6038        .unwrap();
6039        assert!(!replaced, "a current remote binary must not be recopied");
6040        assert_eq!(
6041            executor.commands.borrow().len(),
6042            1,
6043            "only the digest probe runs when the binary is already current"
6044        );
6045    }
6046
6047    #[test]
6048    fn a_remote_recovery_plan_defers_binary_refresh_to_the_recovery_task() {
6049        let locator = ssh_bare_locator("session-remote");
6050        let refresh = worker_binary_refresh_plan(&locator, "session-remote")
6051            .unwrap()
6052            .expect("a remote target now gets a binary refresh");
6053        match refresh {
6054            WorkerBinaryRefresh::Remote(remote) => {
6055                assert_eq!(remote.session_id, "session-remote");
6056                assert_eq!(remote.locator, locator);
6057            }
6058            WorkerBinaryRefresh::Prepared(_) => {
6059                panic!("a remote target must defer, not prepare, its binary refresh")
6060            }
6061        }
6062    }
6063}