Skip to main content

mj_controller/controller/
worker_binary.rs

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