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