Skip to main content

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