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// Container copies can create root-owned files even when exec defaults to a
1586// non-root image user. The worker directory was created by that user, so use
1587// its ownership for uploaded files before restricting their permissions.
1588pub(super) fn container_upload_ownership_args(
1589    container_id: &str,
1590    worker_root: &str,
1591    paths: &[&str],
1592) -> Vec<String> {
1593    let mut args = vec![
1594        "exec".into(),
1595        "--user".into(),
1596        "0".into(),
1597        container_id.into(),
1598        "sh".into(),
1599        "-c".into(),
1600        // GNU and BusyBox stat both support this numeric ownership format.
1601        r#"set -eu; owner=$(stat -c '%u:%g' -- "$1"); shift; chown -R "$owner" -- "$@""#.into(),
1602        "sh".into(),
1603        worker_root.into(),
1604    ];
1605    args.extend(paths.iter().map(|path| (*path).to_owned()));
1606    args
1607}
1608
1609#[allow(clippy::too_many_arguments)]
1610fn install_worker_files(
1611    executor: &impl CommandExecutor,
1612    locator: &hel_targets::TargetLocator,
1613    session_id: &str,
1614    worker_root: &str,
1615    profile_home: &str,
1616    worker_binary: &Path,
1617    launch_config: &Path,
1618    ownership: &Path,
1619    profile_stage: &Path,
1620) -> Result<()> {
1621    match locator {
1622        hel_targets::TargetLocator::LocalBare { .. } => {
1623            if profile_stage.is_dir() {
1624                std::fs::create_dir_all(profile_home).context("create isolated local profile")?;
1625                for entry in std::fs::read_dir(profile_stage)? {
1626                    let entry = entry?;
1627                    copy_profile_entry(
1628                        &entry.path(),
1629                        &Path::new(profile_home).join(entry.file_name()),
1630                    )?;
1631                }
1632            }
1633            for command in [
1634                CommandSpec::new("mkdir", ["-p", worker_root])
1635                    .purpose("create local bare worker directory"),
1636                CommandSpec::new(
1637                    "cp",
1638                    [
1639                        worker_binary.to_string_lossy().into_owned(),
1640                        format!("{worker_root}/hel"),
1641                    ],
1642                )
1643                .purpose("install local Mjolnir worker"),
1644                CommandSpec::new(
1645                    "cp",
1646                    [
1647                        launch_config.to_string_lossy().into_owned(),
1648                        format!("{worker_root}/launch.json"),
1649                    ],
1650                )
1651                .purpose("install local worker launch configuration"),
1652                CommandSpec::new(
1653                    "cp",
1654                    [
1655                        ownership.to_string_lossy().into_owned(),
1656                        format!("{worker_root}/ownership.json"),
1657                    ],
1658                )
1659                .purpose("install local worker ownership marker"),
1660                CommandSpec::new("chmod", ["700", &format!("{worker_root}/hel")])
1661                    .purpose("make local Mjolnir worker executable"),
1662            ] {
1663                execute_checked(executor, command)?;
1664            }
1665        }
1666        hel_targets::TargetLocator::LocalPodman { container_id, .. }
1667        | hel_targets::TargetLocator::LocalDocker { container_id }
1668        | hel_targets::TargetLocator::AppleContainer { container_id } => {
1669            let engine = match locator {
1670                hel_targets::TargetLocator::LocalPodman { .. } => "podman",
1671                hel_targets::TargetLocator::LocalDocker { .. } => "docker",
1672                hel_targets::TargetLocator::AppleContainer { .. } => "container",
1673                _ => unreachable!("matched local container target"),
1674            };
1675            for command in [
1676                CommandSpec::new(
1677                    engine,
1678                    [
1679                        "exec".into(),
1680                        container_id.clone(),
1681                        "mkdir".into(),
1682                        "-p".into(),
1683                        worker_root.into(),
1684                        profile_home.into(),
1685                    ],
1686                )
1687                .purpose("create target worker directories"),
1688                CommandSpec::new(
1689                    engine,
1690                    [
1691                        "cp".into(),
1692                        worker_binary.to_string_lossy().into_owned(),
1693                        format!("{container_id}:{worker_root}/hel"),
1694                    ],
1695                )
1696                .purpose("upload Mjolnir worker"),
1697                CommandSpec::new(
1698                    engine,
1699                    [
1700                        "cp".into(),
1701                        launch_config.to_string_lossy().into_owned(),
1702                        format!("{container_id}:{worker_root}/launch.json"),
1703                    ],
1704                )
1705                .purpose("upload worker launch configuration"),
1706                CommandSpec::new(
1707                    engine,
1708                    [
1709                        "cp".into(),
1710                        ownership.to_string_lossy().into_owned(),
1711                        format!("{container_id}:{worker_root}/ownership.json"),
1712                    ],
1713                )
1714                .purpose("upload worker ownership marker"),
1715                CommandSpec::new(
1716                    engine,
1717                    [
1718                        "cp".into(),
1719                        format!("{}/.", profile_stage.display()),
1720                        format!("{container_id}:{profile_home}"),
1721                    ],
1722                )
1723                .purpose("upload harness profile allowlist"),
1724                CommandSpec::new(
1725                    engine,
1726                    container_upload_ownership_args(
1727                        container_id,
1728                        worker_root,
1729                        &[
1730                            &format!("{worker_root}/hel"),
1731                            &format!("{worker_root}/launch.json"),
1732                            &format!("{worker_root}/ownership.json"),
1733                            profile_home,
1734                        ],
1735                    ),
1736                )
1737                .purpose("assign uploaded files to the worker user"),
1738                CommandSpec::new(
1739                    engine,
1740                    [
1741                        "exec".into(),
1742                        container_id.clone(),
1743                        "chmod".into(),
1744                        "700".into(),
1745                        format!("{worker_root}/hel"),
1746                    ],
1747                )
1748                .purpose("make Mjolnir worker executable"),
1749                CommandSpec::new(
1750                    engine,
1751                    [
1752                        "exec".into(),
1753                        container_id.clone(),
1754                        "chmod".into(),
1755                        "-R".into(),
1756                        "go-rwx".into(),
1757                        profile_home.into(),
1758                    ],
1759                )
1760                .purpose("restrict harness profile permissions"),
1761            ] {
1762                execute_checked(executor, command)?;
1763            }
1764        }
1765        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
1766        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
1767            install_worker_over_ssh(
1768                executor,
1769                ssh,
1770                worker_root,
1771                profile_home,
1772                worker_binary,
1773                launch_config,
1774                ownership,
1775                profile_stage,
1776            )?;
1777        }
1778        hel_targets::TargetLocator::SshPodman {
1779            ssh, container_id, ..
1780        }
1781        | hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
1782            let engine = match locator {
1783                hel_targets::TargetLocator::SshPodman { .. } => "podman",
1784                hel_targets::TargetLocator::SshDocker { .. } => "docker",
1785                _ => unreachable!("matched remote container target"),
1786            };
1787            // The worker binary is 10-30 MB and identical across sessions, so
1788            // keep it in a content-addressed cache on the remote host and copy
1789            // it over the wire only once per unique binary.
1790            let digest = hel::hel_worker_launch::worker_executable_digest(worker_binary)?;
1791            // Home-relative, not "~/": ssh_command_spec single-quotes every
1792            // argument, so a tilde would stay literal in the remote shell
1793            // while scp expands it, and the two sides would disagree. Both
1794            // ssh commands (cwd is the login home) and scp resolve a relative
1795            // path against the remote home.
1796            let cache_dir = format!(".cache/mjolnir/workers/{digest}");
1797            let cached_worker = format!("{cache_dir}/hel");
1798            let cached = matches!(
1799                executor.execute(
1800                    &ssh_command_spec(ssh, ["test", "-f", &cached_worker])
1801                        .purpose("probe cached remote Mjolnir worker"),
1802                ),
1803                Ok(output) if output.status == 0
1804            );
1805            if !cached {
1806                execute_checked(
1807                    executor,
1808                    ssh_command_spec(ssh, ["mkdir", "-p", &cache_dir])
1809                        .purpose("create remote worker cache"),
1810                )?;
1811                let partial = format!("{cache_dir}/hel.partial-{session_id}");
1812                execute_checked(
1813                    executor,
1814                    scp_command_spec(ssh, worker_binary, &partial, false)
1815                        .purpose("upload remote container worker binary"),
1816                )?;
1817                // Rename within the cache directory so the final path only
1818                // ever names a complete upload.
1819                execute_checked(
1820                    executor,
1821                    ssh_command_spec(ssh, ["mv", &partial, &cached_worker])
1822                        .purpose("publish cached remote Mjolnir worker"),
1823                )?;
1824            }
1825            let upload = format!(".cache/mjolnir/uploads/{session_id}");
1826            execute_checked(
1827                executor,
1828                ssh_command_spec(ssh, ["mkdir", "-p", &upload])
1829                    .purpose("create remote upload staging"),
1830            )?;
1831            for (source, name) in [
1832                (launch_config, "launch.json"),
1833                (ownership, "ownership.json"),
1834            ] {
1835                execute_checked(
1836                    executor,
1837                    scp_command_spec(ssh, source, &format!("{upload}/{name}"), false)
1838                        .purpose("upload remote container worker file"),
1839                )?;
1840            }
1841            execute_checked(
1842                executor,
1843                scp_command_spec(ssh, profile_stage, &format!("{upload}/profile"), true)
1844                    .purpose("upload remote container profile allowlist"),
1845            )?;
1846            let remote = [
1847                vec![
1848                    engine.into(),
1849                    "exec".into(),
1850                    container_id.clone(),
1851                    "mkdir".into(),
1852                    "-p".into(),
1853                    worker_root.into(),
1854                    profile_home.into(),
1855                ],
1856                vec![
1857                    engine.into(),
1858                    "cp".into(),
1859                    cached_worker.clone(),
1860                    format!("{container_id}:{worker_root}/hel"),
1861                ],
1862                vec![
1863                    engine.into(),
1864                    "cp".into(),
1865                    format!("{upload}/launch.json"),
1866                    format!("{container_id}:{worker_root}/launch.json"),
1867                ],
1868                vec![
1869                    engine.into(),
1870                    "cp".into(),
1871                    format!("{upload}/ownership.json"),
1872                    format!("{container_id}:{worker_root}/ownership.json"),
1873                ],
1874                vec![
1875                    engine.into(),
1876                    "cp".into(),
1877                    format!("{upload}/profile/."),
1878                    format!("{container_id}:{profile_home}"),
1879                ],
1880                std::iter::once(engine.to_owned())
1881                    .chain(container_upload_ownership_args(
1882                        container_id,
1883                        worker_root,
1884                        &[
1885                            &format!("{worker_root}/hel"),
1886                            &format!("{worker_root}/launch.json"),
1887                            &format!("{worker_root}/ownership.json"),
1888                            profile_home,
1889                        ],
1890                    ))
1891                    .collect(),
1892                vec![
1893                    engine.into(),
1894                    "exec".into(),
1895                    container_id.clone(),
1896                    "chmod".into(),
1897                    "700".into(),
1898                    format!("{worker_root}/hel"),
1899                ],
1900                vec![
1901                    engine.into(),
1902                    "exec".into(),
1903                    container_id.clone(),
1904                    "chmod".into(),
1905                    "-R".into(),
1906                    "go-rwx".into(),
1907                    profile_home.into(),
1908                ],
1909                vec!["rm".into(), "-rf".into(), "--".into(), upload.clone()],
1910            ];
1911            for args in remote {
1912                execute_checked(
1913                    executor,
1914                    ssh_command_spec(ssh, args).purpose("install remote container worker"),
1915                )?;
1916            }
1917        }
1918    }
1919    Ok(())
1920}
1921
1922#[allow(clippy::too_many_arguments)]
1923fn install_worker_over_ssh(
1924    executor: &impl CommandExecutor,
1925    ssh: &SshTarget,
1926    worker_root: &str,
1927    profile_home: &str,
1928    worker_binary: &Path,
1929    launch_config: &Path,
1930    ownership: &Path,
1931    profile_stage: &Path,
1932) -> Result<()> {
1933    execute_checked(
1934        executor,
1935        ssh_command_spec(ssh, ["mkdir", "-p", worker_root, profile_home])
1936            .purpose("create SSH worker directories"),
1937    )?;
1938    for (source, remote, recursive) in [
1939        (worker_binary, format!("{worker_root}/hel"), false),
1940        (launch_config, format!("{worker_root}/launch.json"), false),
1941        (ownership, format!("{worker_root}/ownership.json"), false),
1942    ] {
1943        execute_checked(
1944            executor,
1945            scp_command_spec(ssh, source, &remote, recursive).purpose("upload SSH worker file"),
1946        )?;
1947    }
1948    let incoming_profile = format!("{profile_home}.incoming");
1949    execute_checked(
1950        executor,
1951        scp_command_spec(ssh, profile_stage, &incoming_profile, true)
1952            .purpose("upload SSH harness profile allowlist"),
1953    )?;
1954    execute_checked(
1955        executor,
1956        ssh_command_spec(
1957            ssh,
1958            ["cp", "-R", &format!("{incoming_profile}/."), profile_home],
1959        )
1960        .purpose("install SSH harness profile allowlist"),
1961    )?;
1962    execute_checked(
1963        executor,
1964        ssh_command_spec(ssh, ["rm", "-rf", "--", &incoming_profile])
1965            .purpose("remove SSH profile staging"),
1966    )?;
1967    execute_checked(
1968        executor,
1969        ssh_command_spec(ssh, ["chmod", "700", &format!("{worker_root}/hel")])
1970            .purpose("make SSH worker executable"),
1971    )?;
1972    execute_checked(
1973        executor,
1974        ssh_command_spec(ssh, ["chmod", "-R", "go-rwx", profile_home])
1975            .purpose("restrict SSH harness profile permissions"),
1976    )?;
1977    Ok(())
1978}
1979
1980/// Replace `{worker_root}/hel` with the controller's current worker binary.
1981///
1982/// Checkpoint export starts that path as a new process. A live daemon already
1983/// has the previous inode mapped, so this does not restart it. Writing through
1984/// `hel.next` and renaming avoids `ETXTBSY` on a running image.
1985pub(super) fn replace_installed_worker_binary(
1986    executor: &impl CommandExecutor,
1987    locator: &hel_targets::TargetLocator,
1988    session_id: &str,
1989    worker_binary: &Path,
1990) -> Result<()> {
1991    let plan = installed_worker_binary_replacement_plan(locator, session_id, worker_binary)?;
1992    for command in plan.commands {
1993        execute_checked(executor, command)?;
1994    }
1995    Ok(())
1996}
1997
1998pub(super) fn replace_installed_worker_launch_config(
1999    executor: &impl CommandExecutor,
2000    locator: &hel_targets::TargetLocator,
2001    session_id: &str,
2002    launch: &WorkerLaunchConfig,
2003) -> Result<()> {
2004    let plan = worker_launch_refresh_plan(locator, session_id, launch)?;
2005    for command in plan.replace.commands {
2006        execute_checked(executor, command)?;
2007    }
2008    Ok(())
2009}
2010
2011/// Prepare the exact managed harness using the current worker binary. Remote
2012/// targets receive a separately staged copy; local bare targets run the binary
2013/// directly with a private launch config. The running worker is not stopped or
2014/// replaced, so any failure here leaves the quiet session attachable on its
2015/// previous build.
2016pub(super) fn prepare_managed_harness_for_upgrade(
2017    executor: &impl CommandExecutor,
2018    locator: &hel_targets::TargetLocator,
2019    session_id: &str,
2020    worker_binary: &Path,
2021    launch: &WorkerLaunchConfig,
2022) -> Result<()> {
2023    if launch.harness_runtime != HarnessRuntimePolicy::Managed {
2024        return Ok(());
2025    }
2026    let worker_root = hel_targets::worker_root(locator, session_id)?;
2027    let staging_root = format!("{worker_root}/harness-prepare");
2028    let staging_binary = format!("{staging_root}/hel");
2029    let staging_config = format!("{staging_root}/launch.json");
2030    let staging = tempfile::tempdir().context("create managed harness upgrade staging")?;
2031    let local_config = staging.path().join("launch.json");
2032    launch.write(&local_config)?;
2033
2034    // Local bare workers already share the controller's filesystem. Running
2035    // the current binary against a private launch config is enough to prepare
2036    // the cache, and leaves the live worker root completely untouched.
2037    if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
2038        execute_checked(
2039            executor,
2040            CommandSpec::new(
2041                worker_binary.to_string_lossy().into_owned(),
2042                [
2043                    "worker".to_owned(),
2044                    "prepare-harness".to_owned(),
2045                    "--config".to_owned(),
2046                    local_config.to_string_lossy().into_owned(),
2047                ],
2048            )
2049            .purpose("prepare exact managed harness"),
2050        )?;
2051        return Ok(());
2052    }
2053
2054    let ssh = match locator {
2055        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2056        | hel_targets::TargetLocator::SshBare { ssh, .. } => ssh,
2057        _ => bail!("managed harness policy requires a local bare, SSH-bare, or EC2 target"),
2058    };
2059    let result = (|| {
2060        execute_checked(
2061            executor,
2062            ssh_command_spec(ssh, ["rm", "-rf", "--", &staging_root])
2063                .purpose("clear managed harness preparation staging"),
2064        )?;
2065        execute_checked(
2066            executor,
2067            ssh_command_spec(ssh, ["mkdir", "-p", &staging_root])
2068                .purpose("create managed harness preparation staging"),
2069        )?;
2070        execute_checked(
2071            executor,
2072            scp_command_spec(ssh, worker_binary, &staging_binary, false)
2073                .purpose("stage current worker for managed harness preparation"),
2074        )?;
2075        execute_checked(
2076            executor,
2077            scp_command_spec(ssh, &local_config, &staging_config, false)
2078                .purpose("stage managed harness launch configuration"),
2079        )?;
2080        execute_checked(
2081            executor,
2082            ssh_command_spec(ssh, ["chmod", "700", &staging_binary])
2083                .purpose("make managed harness preparation worker executable"),
2084        )?;
2085        execute_checked(
2086            executor,
2087            ssh_command_spec(
2088                ssh,
2089                [
2090                    staging_binary.as_str(),
2091                    "worker",
2092                    "prepare-harness",
2093                    "--config",
2094                    staging_config.as_str(),
2095                ],
2096            )
2097            .purpose("prepare exact managed harness"),
2098        )?;
2099        Ok(())
2100    })();
2101    let cleanup = execute_checked(
2102        executor,
2103        ssh_command_spec(ssh, ["rm", "-rf", "--", &staging_root])
2104            .purpose("remove managed harness preparation staging"),
2105    );
2106    match (result, cleanup) {
2107        (Ok(()), Ok(_)) => Ok(()),
2108        (Ok(()), Err(error)) => Err(error).context("clean managed harness preparation staging"),
2109        (Err(error), Ok(_)) => Err(error),
2110        (Err(error), Err(cleanup)) => {
2111            tracing::warn!(%cleanup, path = %staging_root, "managed harness preparation staging cleanup failed");
2112            Err(error)
2113        }
2114    }
2115}
2116
2117fn prepare_installed_managed_harness(
2118    executor: &impl CommandExecutor,
2119    locator: &hel_targets::TargetLocator,
2120    worker_root: &str,
2121    launch: &WorkerLaunchConfig,
2122) -> Result<()> {
2123    if launch.harness_runtime != HarnessRuntimePolicy::Managed {
2124        return Ok(());
2125    }
2126    let worker_binary = format!("{worker_root}/hel");
2127    let launch_config = format!("{worker_root}/launch.json");
2128    let command = match locator {
2129        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new(
2130            worker_binary.clone(),
2131            [
2132                "worker",
2133                "prepare-harness",
2134                "--config",
2135                launch_config.as_str(),
2136            ],
2137        ),
2138        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2139        | hel_targets::TargetLocator::SshBare { ssh, .. } => ssh_command_spec(
2140            ssh,
2141            [
2142                worker_binary.as_str(),
2143                "worker",
2144                "prepare-harness",
2145                "--config",
2146                launch_config.as_str(),
2147            ],
2148        ),
2149        _ => bail!("managed harness policy requires a local bare, SSH-bare, or EC2 target"),
2150    };
2151    execute_checked(
2152        executor,
2153        command.purpose("prepare exact managed harness before worker startup"),
2154    )?;
2155    Ok(())
2156}
2157
2158fn installed_worker_binary_replacement_plan(
2159    locator: &hel_targets::TargetLocator,
2160    session_id: &str,
2161    worker_binary: &Path,
2162) -> Result<CommandPlan> {
2163    let worker_root = hel_targets::worker_root(locator, session_id)?;
2164    let installed = format!("{worker_root}/hel");
2165    let staged = format!("{worker_root}/hel.next");
2166    let commands = match locator {
2167        hel_targets::TargetLocator::LocalBare { .. } => vec![
2168            CommandSpec::new(
2169                "cp",
2170                [worker_binary.to_string_lossy().into_owned(), staged.clone()],
2171            )
2172            .purpose("stage replacement Mjolnir worker"),
2173            CommandSpec::new("mv", ["-f", &staged, &installed])
2174                .purpose("replace installed Mjolnir worker"),
2175            CommandSpec::new("chmod", ["700", &installed])
2176                .purpose("make replaced Mjolnir worker executable"),
2177        ],
2178        hel_targets::TargetLocator::LocalPodman { container_id, .. }
2179        | hel_targets::TargetLocator::LocalDocker { container_id }
2180        | hel_targets::TargetLocator::AppleContainer { container_id } => {
2181            let engine = match locator {
2182                hel_targets::TargetLocator::LocalPodman { .. } => "podman",
2183                hel_targets::TargetLocator::LocalDocker { .. } => "docker",
2184                hel_targets::TargetLocator::AppleContainer { .. } => "container",
2185                _ => unreachable!("matched local container target"),
2186            };
2187            vec![
2188                CommandSpec::new(
2189                    engine,
2190                    [
2191                        "cp".into(),
2192                        worker_binary.to_string_lossy().into_owned(),
2193                        format!("{container_id}:{staged}"),
2194                    ],
2195                )
2196                .purpose("stage replacement Mjolnir worker"),
2197                CommandSpec::new(
2198                    engine,
2199                    container_upload_ownership_args(container_id, &worker_root, &[&staged]),
2200                )
2201                .purpose("assign replacement worker to the worker user"),
2202                CommandSpec::new(
2203                    engine,
2204                    [
2205                        "exec".into(),
2206                        container_id.clone(),
2207                        "mv".into(),
2208                        "-f".into(),
2209                        staged,
2210                        installed.clone(),
2211                    ],
2212                )
2213                .purpose("replace installed Mjolnir worker"),
2214                CommandSpec::new(
2215                    engine,
2216                    [
2217                        "exec".into(),
2218                        container_id.clone(),
2219                        "chmod".into(),
2220                        "700".into(),
2221                        installed,
2222                    ],
2223                )
2224                .purpose("make replaced Mjolnir worker executable"),
2225            ]
2226        }
2227        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2228        | hel_targets::TargetLocator::SshBare { ssh, .. } => vec![
2229            scp_command_spec(ssh, worker_binary, &staged, false)
2230                .purpose("stage replacement Mjolnir worker"),
2231            ssh_command_spec(ssh, ["mv", "-f", "--", &staged, &installed])
2232                .purpose("replace installed Mjolnir worker"),
2233            ssh_command_spec(ssh, ["chmod", "700", &installed])
2234                .purpose("make replaced Mjolnir worker executable"),
2235        ],
2236        hel_targets::TargetLocator::SshPodman {
2237            ssh, container_id, ..
2238        }
2239        | hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
2240            let engine = match locator {
2241                hel_targets::TargetLocator::SshPodman { .. } => "podman",
2242                hel_targets::TargetLocator::SshDocker { .. } => "docker",
2243                _ => unreachable!("matched remote container target"),
2244            };
2245            let upload = format!(".cache/mjolnir/uploads/{session_id}-hel.next");
2246            vec![
2247                ssh_command_spec(ssh, ["mkdir", "-p", ".cache/mjolnir/uploads"])
2248                    .purpose("create remote replacement worker staging"),
2249                scp_command_spec(ssh, worker_binary, &upload, false)
2250                    .purpose("stage replacement Mjolnir worker"),
2251                ssh_command_spec(
2252                    ssh,
2253                    [engine, "cp", &upload, &format!("{container_id}:{staged}")],
2254                )
2255                .purpose("stage replacement Mjolnir worker"),
2256                ssh_command_spec(
2257                    ssh,
2258                    std::iter::once(engine.to_owned()).chain(container_upload_ownership_args(
2259                        container_id,
2260                        &worker_root,
2261                        &[&staged],
2262                    )),
2263                )
2264                .purpose("assign replacement worker to the worker user"),
2265                ssh_command_spec(
2266                    ssh,
2267                    [
2268                        engine,
2269                        "exec",
2270                        container_id,
2271                        "mv",
2272                        "-f",
2273                        "--",
2274                        &staged,
2275                        &installed,
2276                    ],
2277                )
2278                .purpose("replace installed Mjolnir worker"),
2279                ssh_command_spec(
2280                    ssh,
2281                    [engine, "exec", container_id, "chmod", "700", &installed],
2282                )
2283                .purpose("make replaced Mjolnir worker executable"),
2284                ssh_command_spec(ssh, ["rm", "-f", "--", &upload])
2285                    .purpose("remove remote replacement worker staging"),
2286            ]
2287        }
2288    };
2289    Ok(CommandPlan {
2290        description: format!("replace stale Mjolnir worker for session {session_id}"),
2291        commands,
2292    })
2293}
2294
2295fn installed_file_digest_command(
2296    locator: &hel_targets::TargetLocator,
2297    path: &str,
2298    purpose: &str,
2299) -> CommandSpec {
2300    match locator {
2301        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sha256sum", [path]),
2302        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2303            CommandSpec::new("podman", ["exec", container_id, "sha256sum", path])
2304        }
2305        hel_targets::TargetLocator::LocalDocker { container_id } => {
2306            CommandSpec::new("docker", ["exec", container_id, "sha256sum", path])
2307        }
2308        hel_targets::TargetLocator::AppleContainer { container_id } => {
2309            CommandSpec::new("container", ["exec", container_id, "sha256sum", path])
2310        }
2311        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2312        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2313            ssh_command_spec(ssh, ["sha256sum", path])
2314        }
2315        hel_targets::TargetLocator::SshPodman {
2316            ssh, container_id, ..
2317        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sha256sum", path]),
2318        hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
2319            ssh_command_spec(ssh, ["docker", "exec", container_id, "sha256sum", path])
2320        }
2321    }
2322    .purpose(purpose)
2323}
2324
2325fn worker_launch_refresh_plan(
2326    locator: &hel_targets::TargetLocator,
2327    session_id: &str,
2328    launch: &WorkerLaunchConfig,
2329) -> Result<WorkerLaunchRefreshPlan> {
2330    let worker_root = hel_targets::worker_root(locator, session_id)?;
2331    let installed = format!("{worker_root}/launch.json");
2332    let staged = format!("{installed}.next");
2333    let staged_arg = hel_targets::join_remote_command(std::slice::from_ref(&staged));
2334    let installed_arg = hel_targets::join_remote_command(std::slice::from_ref(&installed));
2335    let script = format!("umask 077; cat > {staged_arg} && mv -f -- {staged_arg} {installed_arg}");
2336    let body = serde_json::to_vec_pretty(launch).context("serialize worker launch config")?;
2337    let expected_sha256 = format!("{:x}", Sha256::digest(&body));
2338    let replace = match locator {
2339        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2340        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2341            CommandSpec::new("podman", ["exec", "-i", container_id, "sh", "-c", &script])
2342        }
2343        hel_targets::TargetLocator::LocalDocker { container_id } => {
2344            CommandSpec::new("docker", ["exec", "-i", container_id, "sh", "-c", &script])
2345        }
2346        hel_targets::TargetLocator::AppleContainer { container_id } => CommandSpec::new(
2347            "container",
2348            ["exec", "-i", container_id, "sh", "-c", &script],
2349        ),
2350        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2351        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2352            ssh_command_spec(ssh, ["sh", "-c", &script])
2353        }
2354        hel_targets::TargetLocator::SshPodman {
2355            ssh, container_id, ..
2356        } => ssh_command_spec(
2357            ssh,
2358            ["podman", "exec", "-i", container_id, "sh", "-c", &script],
2359        ),
2360        hel_targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
2361            ssh,
2362            ["docker", "exec", "-i", container_id, "sh", "-c", &script],
2363        ),
2364    }
2365    .purpose("replace stale Mjolnir worker launch config")
2366    .with_sensitive_stdin(body);
2367    Ok(WorkerLaunchRefreshPlan {
2368        expected_sha256,
2369        installed_digest: installed_file_digest_command(
2370            locator,
2371            &installed,
2372            "identify installed Mjolnir worker launch config",
2373        ),
2374        replace: CommandPlan {
2375            description: format!("replace stale Mjolnir launch config for session {session_id}"),
2376            commands: vec![replace],
2377        },
2378    })
2379}
2380
2381/// Prepare a local refresh without hashing the controller binary. Digesting
2382/// happens only after recovery has proved that the worker needs a restart.
2383fn worker_binary_refresh_plan(
2384    locator: &hel_targets::TargetLocator,
2385    session_id: &str,
2386) -> Result<Option<WorkerBinaryRefresh>> {
2387    let worker_root = hel_targets::worker_root(locator, session_id)?;
2388    let installed = format!("{worker_root}/hel");
2389    // Remote targets defer source selection to the recovery task: choosing the
2390    // binary needs the target's architecture, and probing it (plus hashing the
2391    // remote binary) is blocking ssh work that must not run on this UI/event
2392    // path. Building the refresh here stays cheap.
2393    if matches!(
2394        locator,
2395        hel_targets::TargetLocator::AwsEc2 { .. }
2396            | hel_targets::TargetLocator::SshBare { .. }
2397            | hel_targets::TargetLocator::SshPodman { .. }
2398            | hel_targets::TargetLocator::SshDocker { .. }
2399    ) {
2400        return Ok(Some(WorkerBinaryRefresh::Remote(
2401            RemoteWorkerBinaryRefresh {
2402                locator: locator.clone(),
2403                session_id: session_id.to_owned(),
2404                installed_digest: installed_file_digest_command(
2405                    locator,
2406                    &installed,
2407                    "identify installed Mjolnir worker binary",
2408                ),
2409            },
2410        )));
2411    }
2412    // Local: resolve the source now. Resolving a deleted running executable
2413    // materializes /proc/self/exe and can copy hundreds of megabytes; target
2414    // lists are assembled on UI/event loops, so leave refresh disabled until
2415    // the next controller start rather than doing that work here.
2416    if PINNED_WORKER_BINARY_SOURCES.get().is_none()
2417        && !std::env::current_exe().is_ok_and(|path| path.is_file())
2418    {
2419        return Ok(None);
2420    }
2421    let requirement = if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
2422        WorkerBinaryRequirement::LocalHost
2423    } else {
2424        WorkerBinaryRequirement::PortableLinux
2425    };
2426    let source = match worker_binary_for_arch(std::env::consts::ARCH, requirement) {
2427        Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2428        Ok(WorkerBinaryAvailability::Remote { .. }) | Err(_) => return Ok(None),
2429    };
2430    Ok(Some(WorkerBinaryRefresh::Prepared(
2431        WorkerBinaryRefreshPlan {
2432            replace: installed_worker_binary_replacement_plan(locator, session_id, &source)?,
2433            source,
2434            installed_digest: installed_file_digest_command(
2435                locator,
2436                &installed,
2437                "identify installed Mjolnir worker binary",
2438            ),
2439        },
2440    )))
2441}
2442
2443/// Refresh a remote worker binary during recovery: pick the worker binary for
2444/// the target's own architecture, and copy it over the installed one only when
2445/// their digests differ. This runs inside the recovery task, where blocking
2446/// ssh work is allowed; it must never be called from a UI/event loop.
2447///
2448/// The digest gate is what stops a redeploy loop: once the right binary is
2449/// installed, its digest matches the source and nothing is copied again, even
2450/// though recovery may still restart the worker.
2451pub(crate) fn refresh_remote_worker_binary_if_stale(
2452    executor: &impl CommandExecutor,
2453    refresh: &RemoteWorkerBinaryRefresh,
2454) -> Result<()> {
2455    let source = worker_binary_for(&refresh.locator, executor)
2456        .context("resolve the worker binary for the recovering target")?;
2457    replace_remote_worker_binary_if_stale(
2458        executor,
2459        &refresh.locator,
2460        &refresh.session_id,
2461        &refresh.installed_digest,
2462        &source,
2463    )
2464    .map(|_| ())
2465}
2466
2467/// Copy `source` over the installed remote worker only when the installed
2468/// digest differs from `source`'s. Returns whether a copy ran. Split from the
2469/// resolver above so the digest gate is testable without resolving a real
2470/// worker binary for a target architecture.
2471fn replace_remote_worker_binary_if_stale(
2472    executor: &impl CommandExecutor,
2473    locator: &hel_targets::TargetLocator,
2474    session_id: &str,
2475    installed_digest: &CommandSpec,
2476    source: &Path,
2477) -> Result<bool> {
2478    let expected = hel::hel_worker_launch::worker_executable_digest(source)?;
2479    let installed = executor
2480        .execute(installed_digest)
2481        .context("read the installed remote worker digest")?;
2482    let matches = installed.status == 0
2483        && String::from_utf8_lossy(&installed.stdout)
2484            .split_whitespace()
2485            .next()
2486            .is_some_and(|digest| digest.eq_ignore_ascii_case(&expected));
2487    if matches {
2488        return Ok(false);
2489    }
2490    installed_worker_binary_replacement_plan(locator, session_id, source)?
2491        .execute(executor)
2492        .context("replace stale remote relay worker binary")?;
2493    Ok(true)
2494}
2495
2496/// Stop the detached worker daemon at `worker_root` without deleting its files.
2497///
2498/// The script signals the worker's process group so a wedged ACP child dies
2499/// with it. Checkpoint then restarts the daemon against the same relay root.
2500pub(super) fn stop_worker(
2501    executor: &impl CommandExecutor,
2502    locator: &hel_targets::TargetLocator,
2503    worker_root: &str,
2504) -> Result<()> {
2505    execute_checked(executor, stop_worker_command(locator, worker_root))?;
2506    Ok(())
2507}
2508
2509/// Restore a stopped Podman target before signaling its worker. Checkpoint
2510/// recovery uses this instead of assuming every persisted target is running.
2511pub(super) fn stop_worker_after_target_recovery(
2512    executor: &impl CommandExecutor,
2513    locator: &hel_targets::TargetLocator,
2514    session_id: &str,
2515    worker_root: &str,
2516) -> Result<()> {
2517    let target = hel_targets::target_recovery_plan(locator, session_id)?;
2518    hel_targets::ensure_recovery_target_running(executor, target.as_ref())
2519        .context("restore Mjolnir worker target")?;
2520    stop_worker(executor, locator, worker_root)
2521}
2522
2523fn stop_worker_command(locator: &hel_targets::TargetLocator, worker_root: &str) -> CommandSpec {
2524    let script = hel_targets::stop_worker_daemon_script(worker_root);
2525    match locator {
2526        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2527        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2528            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
2529        }
2530        hel_targets::TargetLocator::LocalDocker { container_id } => {
2531            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
2532        }
2533        hel_targets::TargetLocator::AppleContainer { container_id } => {
2534            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
2535        }
2536        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2537        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2538            ssh_command_spec(ssh, ["sh", "-c", &script])
2539        }
2540        hel_targets::TargetLocator::SshPodman {
2541            ssh, container_id, ..
2542        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
2543        hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
2544            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
2545        }
2546    }
2547    .purpose("stop Mjolnir worker daemon")
2548}
2549
2550fn worker_liveness_command(locator: &hel_targets::TargetLocator, worker_root: &str) -> CommandSpec {
2551    let script = hel_targets::worker_daemon_liveness_script(worker_root);
2552    match locator {
2553        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2554        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2555            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
2556        }
2557        hel_targets::TargetLocator::LocalDocker { container_id } => {
2558            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
2559        }
2560        hel_targets::TargetLocator::AppleContainer { container_id } => {
2561            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
2562        }
2563        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2564        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2565            ssh_command_spec(ssh, ["sh", "-c", &script])
2566        }
2567        hel_targets::TargetLocator::SshPodman {
2568            ssh, container_id, ..
2569        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
2570        hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
2571            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
2572        }
2573    }
2574    .purpose("probe Mjolnir worker daemon liveness")
2575}
2576
2577pub(super) fn start_worker(
2578    executor: &impl CommandExecutor,
2579    locator: &hel_targets::TargetLocator,
2580    worker_root: &str,
2581) -> Result<()> {
2582    execute_checked(executor, start_worker_command(locator, worker_root))?;
2583    Ok(())
2584}
2585
2586fn start_worker_command(locator: &hel_targets::TargetLocator, worker_root: &str) -> CommandSpec {
2587    let binary = format!("{worker_root}/hel");
2588    let config = format!("{worker_root}/launch.json");
2589    // These files describe the worker's previous life. Clear them as part of
2590    // the launch, before the new daemon can be probed: a stale exit record
2591    // aborts startup, while a stale socket makes a recovering daemon look
2592    // ready and invites the reconnect actor to kill it as unresponsive.
2593    let clear_stale_runtime = format!(
2594        "rm -f {} {}; ",
2595        hel_targets::join_remote_command(&[format!("{worker_root}/worker-exit.json")]),
2596        hel_targets::join_remote_command(&[format!("{worker_root}/control.sock")]),
2597    );
2598    let detached_script = format!(
2599        "{clear_stale_runtime}nohup {} >{} 2>&1 </dev/null &",
2600        hel_targets::join_remote_command(&[
2601            binary.clone(),
2602            "worker".into(),
2603            "run".into(),
2604            "--root".into(),
2605            worker_root.into(),
2606            "--config".into(),
2607            config.clone(),
2608        ]),
2609        hel_targets::join_remote_command(&[format!("{worker_root}/worker.log")]),
2610    );
2611    // Redirect daemon output to worker.log in every launch mode; an
2612    // unexplained dead worker is undebuggable without it.
2613    let exec_script = format!(
2614        "{clear_stale_runtime}exec {} >{} 2>&1",
2615        hel_targets::join_remote_command(&[
2616            binary.clone(),
2617            "worker".into(),
2618            "run".into(),
2619            "--root".into(),
2620            worker_root.into(),
2621            "--config".into(),
2622            config.clone(),
2623        ]),
2624        hel_targets::join_remote_command(&[format!("{worker_root}/worker.log")]),
2625    );
2626    match locator {
2627        hel_targets::TargetLocator::LocalBare { .. } => {
2628            CommandSpec::new("sh", ["-c", &detached_script])
2629        }
2630        hel_targets::TargetLocator::LocalPodman { container_id, .. } => CommandSpec::new(
2631            "podman",
2632            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
2633        ),
2634        hel_targets::TargetLocator::LocalDocker { container_id } => CommandSpec::new(
2635            "docker",
2636            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
2637        ),
2638        hel_targets::TargetLocator::AppleContainer { container_id } => CommandSpec::new(
2639            "container",
2640            ["exec", "--detach", container_id, "sh", "-c", &exec_script],
2641        ),
2642        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2643        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2644            ssh_command_spec(ssh, ["sh", "-c", &detached_script])
2645        }
2646        hel_targets::TargetLocator::SshPodman {
2647            ssh, container_id, ..
2648        } => ssh_command_spec(
2649            ssh,
2650            [
2651                "podman",
2652                "exec",
2653                "--detach",
2654                container_id,
2655                "sh",
2656                "-c",
2657                &exec_script,
2658            ],
2659        ),
2660        hel_targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
2661            ssh,
2662            [
2663                "docker",
2664                "exec",
2665                "--detach",
2666                container_id,
2667                "sh",
2668                "-c",
2669                &exec_script,
2670            ],
2671        ),
2672    }
2673    .purpose("start detached Mjolnir worker")
2674    // Everything before this moves data into the target and reports as Sync.
2675    // Start begins here, with the daemon launch.
2676    .stage(ProvisionStage::Starting)
2677}
2678
2679/// Enrich an opaque handshake failure by running the installed worker binary
2680/// directly in the target. This surfaces loader errors (for example a
2681/// glibc-linked worker inside an older-glibc container) that a detached start
2682/// swallows.
2683pub(super) fn worker_probe_diagnosis(
2684    executor: &impl CommandExecutor,
2685    locator: &hel_targets::TargetLocator,
2686    worker_root: &str,
2687    error: anyhow::Error,
2688) -> anyhow::Error {
2689    let error = match worker_binary_probe_failure(executor, locator, worker_root) {
2690        Some(failure) => error.context(failure),
2691        None => error,
2692    };
2693    match worker_last_words(executor, locator, worker_root) {
2694        Some(last_words) => error.context(last_words),
2695        None => error,
2696    }
2697}
2698
2699fn worker_binary_probe_failure(
2700    executor: &impl CommandExecutor,
2701    locator: &hel_targets::TargetLocator,
2702    worker_root: &str,
2703) -> Option<String> {
2704    let binary = format!("{worker_root}/hel");
2705    let command = match locator {
2706        hel_targets::TargetLocator::LocalBare { .. } => {
2707            CommandSpec::new(binary.clone(), ["--version"])
2708        }
2709        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2710            CommandSpec::new("podman", ["exec", container_id, &binary, "--version"])
2711        }
2712        hel_targets::TargetLocator::LocalDocker { container_id } => {
2713            CommandSpec::new("docker", ["exec", container_id, &binary, "--version"])
2714        }
2715        hel_targets::TargetLocator::AppleContainer { container_id } => {
2716            CommandSpec::new("container", ["exec", container_id, &binary, "--version"])
2717        }
2718        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2719        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2720            ssh_command_spec(ssh, [binary.as_str(), "--version"])
2721        }
2722        hel_targets::TargetLocator::SshPodman {
2723            ssh, container_id, ..
2724        } => ssh_command_spec(
2725            ssh,
2726            ["podman", "exec", container_id, binary.as_str(), "--version"],
2727        ),
2728        hel_targets::TargetLocator::SshDocker { ssh, container_id } => ssh_command_spec(
2729            ssh,
2730            ["docker", "exec", container_id, binary.as_str(), "--version"],
2731        ),
2732    }
2733    .purpose("probe installed worker binary");
2734    match executor.execute(&command) {
2735        Ok(output) if output.status == 0 => None,
2736        Ok(output) => {
2737            let stderr = String::from_utf8_lossy(&output.stderr);
2738            let stdout = String::from_utf8_lossy(&output.stdout);
2739            let detail = if !stderr.trim().is_empty() {
2740                stderr.trim()
2741            } else if !stdout.trim().is_empty() {
2742                stdout.trim()
2743            } else {
2744                "the process exited unsuccessfully without output"
2745            };
2746            Some(format!(
2747                "worker binary {binary} fails to run in the target: {detail}; \
2748                 if this is a loader/glibc error, provide a musl worker \
2749                 (cargo build --release --target <arch>-unknown-linux-musl \
2750                  -p brokk-mj-worker --bin mj-worker, \
2751                 or set MJ_WORKER_BINARY/MJ_WORKER_DIR)"
2752            ))
2753        }
2754        Err(probe_error) => Some(format!("worker probe failed: {probe_error:#}")),
2755    }
2756}
2757
2758/// Fetch the dead worker's structured exit record and log tail from the
2759/// target, so unreachable-worker errors carry the root cause.
2760pub(super) fn worker_last_words(
2761    executor: &impl CommandExecutor,
2762    locator: &hel_targets::TargetLocator,
2763    worker_root: &str,
2764) -> Option<String> {
2765    let script = format!(
2766        "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",
2767        root = worker_root,
2768        marker = WORKER_EXIT_RECORD_MARKER
2769    );
2770    let command = match locator {
2771        hel_targets::TargetLocator::LocalBare { .. } => CommandSpec::new("sh", ["-c", &script]),
2772        hel_targets::TargetLocator::LocalPodman { container_id, .. } => {
2773            CommandSpec::new("podman", ["exec", container_id, "sh", "-c", &script])
2774        }
2775        hel_targets::TargetLocator::LocalDocker { container_id } => {
2776            CommandSpec::new("docker", ["exec", container_id, "sh", "-c", &script])
2777        }
2778        hel_targets::TargetLocator::AppleContainer { container_id } => {
2779            CommandSpec::new("container", ["exec", container_id, "sh", "-c", &script])
2780        }
2781        hel_targets::TargetLocator::AwsEc2 { ssh, .. }
2782        | hel_targets::TargetLocator::SshBare { ssh, .. } => {
2783            ssh_command_spec(ssh, ["sh", "-c", &script])
2784        }
2785        hel_targets::TargetLocator::SshPodman {
2786            ssh, container_id, ..
2787        } => ssh_command_spec(ssh, ["podman", "exec", container_id, "sh", "-c", &script]),
2788        hel_targets::TargetLocator::SshDocker { ssh, container_id } => {
2789            ssh_command_spec(ssh, ["docker", "exec", container_id, "sh", "-c", &script])
2790        }
2791    }
2792    .purpose("collect worker last words");
2793    let output = match executor.execute(&command) {
2794        Ok(output) => output,
2795        Err(error) => {
2796            tracing::debug!(
2797                worker_root,
2798                %error,
2799                "could not collect worker diagnostics"
2800            );
2801            return None;
2802        }
2803    };
2804    if output.status != 0 {
2805        tracing::debug!(
2806            worker_root,
2807            status = output.status,
2808            "worker diagnostic probe returned a failure"
2809        );
2810        return None;
2811    }
2812    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
2813    (!text.is_empty()).then(|| format!("worker diagnostics:\n{text}"))
2814}
2815
2816#[cfg(test)]
2817mod tests {
2818    use super::*;
2819
2820    use anyhow::Result;
2821
2822    use hel::hel_config::ExecutionPolicy;
2823    use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
2824
2825    use sha2::{Digest, Sha256};
2826    use std::cell::RefCell;
2827    use std::collections::BTreeMap;
2828
2829    use std::path::{Path, PathBuf};
2830
2831    #[test]
2832    fn a_stored_setup_token_reaches_only_claude_workers_that_do_not_set_their_own() {
2833        use hel::hel_config::HarnessKind;
2834        use hel::hel_credentials::{CLAUDE_OAUTH_TOKEN_ENV, write_claude_oauth_token};
2835
2836        let directory = tempfile::tempdir().unwrap();
2837        let token_path = directory.path().join("profiles/claude/claude-oauth-token");
2838        let missing = directory.path().join("profiles/absent/claude-oauth-token");
2839        write_claude_oauth_token(&token_path, b"sk-ant-oat01-stored").unwrap();
2840
2841        let mut claude = BTreeMap::new();
2842        apply_claude_setup_token(&mut claude, HarnessKind::Claude, &token_path);
2843        assert_eq!(
2844            claude.get(CLAUDE_OAUTH_TOKEN_ENV).map(String::as_str),
2845            Some("sk-ant-oat01-stored")
2846        );
2847
2848        // Every other harness ignores the variable, so it must not appear.
2849        for kind in HarnessKind::ALL
2850            .into_iter()
2851            .filter(|kind| *kind != HarnessKind::Claude)
2852        {
2853            let mut environment = BTreeMap::new();
2854            apply_claude_setup_token(&mut environment, kind, &token_path);
2855            assert!(environment.is_empty(), "{kind:?} must not read the token");
2856        }
2857
2858        // A profile that sets the variable itself stays authoritative.
2859        let mut overridden = BTreeMap::from([(
2860            CLAUDE_OAUTH_TOKEN_ENV.to_owned(),
2861            "profile-token".to_owned(),
2862        )]);
2863        apply_claude_setup_token(&mut overridden, HarnessKind::Claude, &token_path);
2864        assert_eq!(
2865            overridden.get(CLAUDE_OAUTH_TOKEN_ENV).map(String::as_str),
2866            Some("profile-token")
2867        );
2868
2869        // A profile with no stored token launches exactly as before.
2870        let mut without = BTreeMap::new();
2871        apply_claude_setup_token(&mut without, HarnessKind::Claude, &missing);
2872        assert!(without.is_empty());
2873    }
2874
2875    #[test]
2876    fn packaged_worker_names_match_release_archives() {
2877        let directory = Path::new("/opt/hel/bin");
2878        assert_eq!(
2879            packaged_worker_binary_path(directory, "x86_64-unknown-linux-musl"),
2880            directory.join("mj-worker-x86_64-unknown-linux-musl")
2881        );
2882        assert_eq!(
2883            packaged_worker_binary_path(directory, "aarch64-unknown-linux-musl"),
2884            directory.join("mj-worker-aarch64-unknown-linux-musl")
2885        );
2886    }
2887
2888    #[test]
2889    fn pinned_snapshot_keeps_native_and_portable_sources_stable() {
2890        let directory = tempfile::tempdir().unwrap();
2891        let native = directory.path().join("native-worker");
2892        let x86 = directory.path().join("x86-worker");
2893        let arm = directory.path().join("arm-worker");
2894        std::fs::write(&native, b"native bytes").unwrap();
2895        std::fs::write(&x86, b"x86 bytes").unwrap();
2896        std::fs::write(&arm, b"arm bytes").unwrap();
2897        let cache = directory.path().join("cache");
2898        let snapshot = WorkerBinarySourceSnapshot::capture(&cache, |arch, requirement| {
2899            let path = match requirement {
2900                WorkerBinaryRequirement::LocalHost => &native,
2901                WorkerBinaryRequirement::PortableLinux if arch == "x86_64" => &x86,
2902                WorkerBinaryRequirement::PortableLinux => &arm,
2903            };
2904            Ok(WorkerBinaryAvailability::Local {
2905                path: path.clone(),
2906                source: format!("{arch}-{requirement:?}"),
2907            })
2908        });
2909
2910        let native = snapshot
2911            .resolve(std::env::consts::ARCH, WorkerBinaryRequirement::LocalHost)
2912            .unwrap();
2913        let x86 = snapshot
2914            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
2915            .unwrap();
2916        let arm = snapshot
2917            .resolve("aarch64", WorkerBinaryRequirement::PortableLinux)
2918            .unwrap();
2919        let WorkerBinaryAvailability::Local { path: native, .. } = native else {
2920            panic!("native source should be local");
2921        };
2922        let WorkerBinaryAvailability::Local { path: x86, .. } = x86 else {
2923            panic!("x86 source should be local");
2924        };
2925        let WorkerBinaryAvailability::Local { path: arm, .. } = arm else {
2926            panic!("arm source should be local");
2927        };
2928        assert_eq!(std::fs::read(native).unwrap(), b"native bytes");
2929        assert_eq!(std::fs::read(x86).unwrap(), b"x86 bytes");
2930        assert_eq!(std::fs::read(arm).unwrap(), b"arm bytes");
2931    }
2932
2933    #[test]
2934    fn pinned_snapshot_survives_source_replacement_and_missing_candidate_install() {
2935        let directory = tempfile::tempdir().unwrap();
2936        let source = directory.path().join("worker");
2937        std::fs::write(&source, b"before").unwrap();
2938        let cache = directory.path().join("cache");
2939        let resolve_source = |_: &str, _: WorkerBinaryRequirement| {
2940            Ok(WorkerBinaryAvailability::Local {
2941                path: source.clone(),
2942                source: "test source".into(),
2943            })
2944        };
2945        let pinned = WorkerBinarySourceSnapshot::capture(&cache, resolve_source);
2946
2947        std::fs::write(&source, b"in-place mutation").unwrap();
2948        let WorkerBinaryAvailability::Local { path, .. } = pinned
2949            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
2950            .unwrap()
2951        else {
2952            panic!("source should be local");
2953        };
2954        assert_eq!(std::fs::read(path).unwrap(), b"before");
2955
2956        let replacement = directory.path().join("replacement");
2957        std::fs::write(&replacement, b"after").unwrap();
2958        std::fs::rename(replacement, &source).unwrap();
2959        let WorkerBinaryAvailability::Local { path, .. } = pinned
2960            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
2961            .unwrap()
2962        else {
2963            panic!("source should be local");
2964        };
2965        assert_eq!(std::fs::read(path).unwrap(), b"before");
2966        let fresh_replaced = WorkerBinarySourceSnapshot::capture(&cache, resolve_source);
2967        let WorkerBinaryAvailability::Local { path, .. } = fresh_replaced
2968            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
2969            .unwrap()
2970        else {
2971            panic!("source should be local");
2972        };
2973        assert_eq!(std::fs::read(path).unwrap(), b"after");
2974
2975        let missing = directory.path().join("missing-worker");
2976        let missing_snapshot = WorkerBinarySourceSnapshot::capture(&cache, {
2977            let missing = missing.clone();
2978            move |_: &str, _: WorkerBinaryRequirement| {
2979                if missing.is_file() {
2980                    Ok(WorkerBinaryAvailability::Local {
2981                        path: missing.clone(),
2982                        source: "new source".into(),
2983                    })
2984                } else {
2985                    Err(anyhow::anyhow!("candidate is unavailable"))
2986                }
2987            }
2988        });
2989        std::fs::write(&missing, b"now installed").unwrap();
2990        assert!(
2991            missing_snapshot
2992                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
2993                .is_err()
2994        );
2995        let fresh_snapshot = WorkerBinarySourceSnapshot::capture(&cache, {
2996            let missing = missing.clone();
2997            move |_: &str, _: WorkerBinaryRequirement| {
2998                Ok(WorkerBinaryAvailability::Local {
2999                    path: missing.clone(),
3000                    source: "new source".into(),
3001                })
3002            }
3003        });
3004        assert!(
3005            fresh_snapshot
3006                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3007                .is_ok()
3008        );
3009
3010        let remote_url = std::cell::RefCell::new("https://old.example/{target}".to_owned());
3011        let remote_snapshot = WorkerBinarySourceSnapshot::capture(
3012            &directory.path().join("remote-cache"),
3013            |arch, _| {
3014                Ok(WorkerBinaryAvailability::Remote {
3015                    url: remote_url.borrow().replace("{target}", arch),
3016                    sha256: "a".repeat(64),
3017                    triple: format!("{arch}-unknown-linux-musl"),
3018                })
3019            },
3020        );
3021        *remote_url.borrow_mut() = "https://new.example/{target}".into();
3022        let WorkerBinaryAvailability::Remote { url, .. } = remote_snapshot
3023            .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3024            .unwrap()
3025        else {
3026            panic!("source should be remote");
3027        };
3028        assert_eq!(url, "https://old.example/x86_64");
3029
3030        let blocked_cache = directory.path().join("blocked-cache");
3031        std::fs::write(&blocked_cache, b"not a directory").unwrap();
3032        let failed_snapshot = WorkerBinarySourceSnapshot::capture(&blocked_cache, resolve_source);
3033        assert!(
3034            failed_snapshot
3035                .resolve("x86_64", WorkerBinaryRequirement::PortableLinux)
3036                .is_err()
3037        );
3038    }
3039
3040    #[test]
3041    fn dev_checkout_prefers_the_dedicated_musl_worker() {
3042        let controller = PathBuf::from("target/debug/mj");
3043        let musl = PathBuf::from("target/worker/x86_64-unknown-linux-musl/debug/mj-worker");
3044        let shared_target_worker =
3045            PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj-worker");
3046        let legacy = PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj");
3047        let present = [
3048            controller.clone(),
3049            musl.clone(),
3050            shared_target_worker,
3051            legacy,
3052        ];
3053        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3054            present.iter().any(|p| p == path)
3055        });
3056        assert_eq!(
3057            selected,
3058            Some((musl, "isolated development musl worker")),
3059            "the dedicated worker must win over legacy artifacts"
3060        );
3061    }
3062
3063    #[test]
3064    fn local_bare_may_use_a_native_worker_beside_the_controller() {
3065        let controller = PathBuf::from("target/debug/mj");
3066        let worker = PathBuf::from("target/debug/mj-worker");
3067        let selected = worker_binary_prerequisite_for_current(
3068            std::env::consts::ARCH,
3069            WorkerBinaryRequirement::LocalHost,
3070            &controller,
3071            &|path| path == controller || path == worker,
3072        )
3073        .unwrap();
3074        assert_eq!(
3075            selected,
3076            WorkerBinaryAvailability::Local {
3077                path: worker,
3078                source: "native worker beside mj".into(),
3079            }
3080        );
3081    }
3082
3083    #[test]
3084    fn local_bare_prefers_the_isolated_native_development_worker() {
3085        let controller = PathBuf::from("target/debug/mj");
3086        let worker = PathBuf::from("target/worker/debug/mj-worker");
3087        let packaged = PathBuf::from("target/debug/mj-worker");
3088        let selected = worker_binary_prerequisite_for_current(
3089            std::env::consts::ARCH,
3090            WorkerBinaryRequirement::LocalHost,
3091            &controller,
3092            &|path| path == controller || path == worker || path == packaged,
3093        )
3094        .unwrap();
3095        assert_eq!(
3096            selected,
3097            WorkerBinaryAvailability::Local {
3098                path: worker,
3099                source: "isolated native development worker".into(),
3100            }
3101        );
3102    }
3103
3104    #[cfg(target_os = "linux")]
3105    #[test]
3106    fn replaced_dev_controller_still_finds_its_musl_sibling() {
3107        let controller = PathBuf::from("target/debug/mj (deleted)");
3108        let musl = PathBuf::from("target/x86_64-unknown-linux-musl/debug/mj");
3109        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3110            path == musl
3111        });
3112
3113        assert_eq!(selected, Some((musl, "development musl sibling")));
3114    }
3115
3116    #[cfg(target_os = "linux")]
3117    #[test]
3118    fn replaced_dev_controller_never_selects_the_new_glibc_controller_as_its_worker() {
3119        let controller = PathBuf::from("target/debug/mj (deleted)");
3120        let replacement = PathBuf::from("target/debug/mj");
3121        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3122            path == replacement
3123        });
3124
3125        assert_eq!(selected, None);
3126    }
3127
3128    /// A configured container template for the preflight tests. Only the
3129    /// platform matters here; the rest is the smallest valid template.
3130    fn container_template(platform: Option<&str>) -> hel::hel_config::ContainerTemplate {
3131        hel::hel_config::ContainerTemplate {
3132            image: "example.invalid/mj-test:latest".into(),
3133            pull_policy: Default::default(),
3134            platform: platform.map(str::to_owned),
3135            cpus: None,
3136            memory: None,
3137            environment: BTreeMap::new(),
3138            workspace_storage: Default::default(),
3139        }
3140    }
3141
3142    fn ssh_connection() -> hel::hel_config::SshConnection {
3143        hel::hel_config::SshConnection {
3144            host: "builder".into(),
3145            user: Some("dev".into()),
3146            identity_file: None,
3147            extra_args: Vec::new(),
3148        }
3149    }
3150
3151    #[test]
3152    fn recovery_workspace_uses_the_launch_directory_for_bare_targets_only() {
3153        let cwd = PathBuf::from("/workspace/session/project");
3154        let local = worker_workspace_for_recovery(
3155            &hel_targets::TargetLocator::LocalBare {
3156                worker_root: "/workspace/session/worker".into(),
3157            },
3158            &cwd,
3159        )
3160        .expect("local bare targets need a workspace probe");
3161        assert_eq!(local.directory, cwd);
3162        assert_eq!(local.target, hel::hel_state::ManagedWorktreeTarget::Local);
3163
3164        let remote = worker_workspace_for_recovery(
3165            &hel_targets::TargetLocator::SshBare {
3166                ssh: SshTarget {
3167                    destination: "dev@builder".into(),
3168                    ssh_args: vec!["-oBatchMode=yes".into()],
3169                },
3170                workspace: "/workspace/session".into(),
3171            },
3172            &cwd,
3173        )
3174        .expect("SSH bare targets need a workspace probe");
3175        assert_eq!(remote.directory, cwd);
3176        assert_eq!(
3177            remote.target,
3178            hel::hel_state::ManagedWorktreeTarget::Ssh {
3179                destination: "dev@builder".into(),
3180                ssh_args: vec!["-oBatchMode=yes".into()],
3181            }
3182        );
3183
3184        assert!(
3185            worker_workspace_for_recovery(
3186                &hel_targets::TargetLocator::LocalPodman {
3187                    container_id: "container".into(),
3188                    workspace_storage: Default::default(),
3189                },
3190                &cwd,
3191            )
3192            .is_none()
3193        );
3194        assert!(
3195            worker_workspace_for_recovery(
3196                &hel_targets::TargetLocator::AwsEc2 {
3197                    profile: "default".into(),
3198                    region: "us-east-1".into(),
3199                    instance_id: "i-test".into(),
3200                    ssh: SshTarget {
3201                        destination: "dev@builder".into(),
3202                        ssh_args: Vec::new(),
3203                    },
3204                    workspace: "/workspace/session".into(),
3205                },
3206                &cwd,
3207            )
3208            .is_none()
3209        );
3210    }
3211
3212    #[test]
3213    fn preflight_reads_the_architecture_a_template_names() {
3214        use hel::hel_config::TargetTemplate;
3215
3216        for (platform, expected) in [
3217            ("linux/arm64", "aarch64"),
3218            ("linux/arm64/v8", "aarch64"),
3219            ("linux/amd64", "x86_64"),
3220            ("aarch64", "aarch64"),
3221        ] {
3222            assert_eq!(
3223                preflight_architectures(&TargetTemplate::LocalPodman {
3224                    container: container_template(Some(platform)),
3225                }),
3226                vec![expected],
3227                "platform {platform}"
3228            );
3229        }
3230        // A named platform decides a remote container target too, so a resume
3231        // onto an arm64 container never asks about the host's architecture.
3232        assert_eq!(
3233            preflight_architectures(&TargetTemplate::SshPodman {
3234                ssh: ssh_connection(),
3235                container: container_template(Some("linux/arm64")),
3236            }),
3237            vec!["aarch64"]
3238        );
3239    }
3240
3241    #[test]
3242    fn preflight_uses_the_host_architecture_for_a_local_target() {
3243        use hel::hel_config::TargetTemplate;
3244
3245        for template in [
3246            TargetTemplate::LocalBare,
3247            TargetTemplate::LocalPodman {
3248                container: container_template(None),
3249            },
3250            TargetTemplate::LocalDocker {
3251                container: container_template(None),
3252            },
3253            TargetTemplate::AppleContainer {
3254                container: container_template(None),
3255            },
3256        ] {
3257            assert_eq!(
3258                preflight_architectures(&template),
3259                vec![std::env::consts::ARCH],
3260                "{template:?}"
3261            );
3262        }
3263    }
3264
3265    #[test]
3266    fn preflight_accepts_either_linux_architecture_for_a_remote_target() {
3267        use hel::hel_config::TargetTemplate;
3268
3269        // Nothing in the configuration says what a remote machine runs, so the
3270        // preflight passes as long as one architecture could be served; the
3271        // real architecture is read from the live target during provisioning.
3272        for template in [
3273            TargetTemplate::SshBare {
3274                ssh: ssh_connection(),
3275                permissions: hel::hel_config::PermissionMode::Yolo,
3276                workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
3277            },
3278            TargetTemplate::SshPodman {
3279                ssh: ssh_connection(),
3280                container: container_template(None),
3281            },
3282            TargetTemplate::AwsEc2 {
3283                aws_profile: None,
3284                region: "us-east-1".into(),
3285                launch_template: "lt-mj".into(),
3286                launch_template_version: None,
3287                ssh_user: "dev".into(),
3288                address_source: Default::default(),
3289                identity_file: None,
3290                ssh_args: Vec::new(),
3291            },
3292        ] {
3293            assert_eq!(
3294                preflight_architectures(&template),
3295                vec!["x86_64", "aarch64"],
3296                "{template:?}"
3297            );
3298        }
3299    }
3300
3301    #[test]
3302    fn dev_checkout_still_finds_a_hel_named_sibling() {
3303        let controller = PathBuf::from("target/debug/hel");
3304        let musl = PathBuf::from("target/x86_64-unknown-linux-musl/debug/hel");
3305        let present = [controller.clone(), musl.clone()];
3306        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3307            present.iter().any(|p| p == path)
3308        });
3309        assert_eq!(selected, Some((musl, "development musl sibling")));
3310    }
3311
3312    /// An architecture no host builds for, so the lookup cannot take one of
3313    /// the "native mj binary" shortcuts and reaches the end on any machine.
3314    const FOREIGN_ARCH: &str = "riscv64";
3315
3316    /// A rebuilt or renamed checkout leaves a running daemon pointing at a
3317    /// path that holds nothing. Searching beside that path finds nothing and
3318    /// blames the user for a worker that may well be installed correctly.
3319    #[test]
3320    fn a_replaced_controller_is_reported_instead_of_a_missing_worker() {
3321        let stale = PathBuf::from("/src/.backup-vHXvCs/target/debug/mj (deleted)");
3322        let probed = RefCell::new(Vec::new());
3323
3324        let error = worker_binary_prerequisite_for_current(
3325            FOREIGN_ARCH,
3326            WorkerBinaryRequirement::PortableLinux,
3327            &stale,
3328            &|path| {
3329                probed.borrow_mut().push(path.to_path_buf());
3330                false
3331            },
3332        )
3333        .unwrap_err();
3334
3335        let detail = format!("{error:#}");
3336        assert!(
3337            detail.contains("was replaced or removed on disk"),
3338            "{detail}"
3339        );
3340        assert!(detail.contains("restart the Mjolnir daemon"), "{detail}");
3341        // The path is named without the kernel's deletion marker.
3342        assert!(
3343            detail.contains("/src/.backup-vHXvCs/target/debug/mj)"),
3344            "{detail}"
3345        );
3346        assert!(!detail.contains("(deleted)"), "{detail}");
3347        assert_eq!(
3348            probed.into_inner(),
3349            vec![stale],
3350            "nothing beside a path that no longer exists is worth probing"
3351        );
3352    }
3353
3354    /// The guard is about a controller path that no longer exists and nothing
3355    /// else: a controller still on disk keeps its whole sibling lookup, and
3356    /// keeps the plain "no Linux worker" answer when that lookup comes up
3357    /// empty. A present controller is never its own portable worker, so with
3358    /// nothing installed beside it the lookup ends in that plain answer.
3359    #[test]
3360    fn a_present_controller_still_looks_beside_itself() {
3361        let controller = PathBuf::from("/opt/brokk/mj");
3362        let probed = RefCell::new(Vec::new());
3363
3364        let error = worker_binary_prerequisite_for_current(
3365            FOREIGN_ARCH,
3366            WorkerBinaryRequirement::PortableLinux,
3367            &controller,
3368            &|path| {
3369                probed.borrow_mut().push(path.to_path_buf());
3370                path == controller
3371            },
3372        )
3373        .unwrap_err();
3374
3375        let probed = probed.into_inner();
3376        assert!(
3377            probed
3378                .iter()
3379                .any(|path| path.ends_with("mj-worker-riscv64-unknown-linux-musl")),
3380            "the packaged worker name must still be probed: {probed:?}"
3381        );
3382        let detail = format!("{error:#}");
3383        assert!(
3384            detail.contains("no Linux worker for riscv64-unknown-linux-musl"),
3385            "{detail}"
3386        );
3387        assert!(!detail.contains("restart the Mjolnir daemon"), "{detail}");
3388
3389        // With nothing beside it either, a present controller still gets the
3390        // generic message; only a replaced one is told to restart.
3391        let root = PathBuf::from("/");
3392        let error = worker_binary_prerequisite_for_current(
3393            FOREIGN_ARCH,
3394            WorkerBinaryRequirement::PortableLinux,
3395            &root,
3396            &|path| path == root,
3397        )
3398        .unwrap_err();
3399        let detail = format!("{error:#}");
3400        assert!(
3401            detail.contains("no Linux worker for riscv64-unknown-linux-musl"),
3402            "{detail}"
3403        );
3404        assert!(!detail.contains("restart the Mjolnir daemon"), "{detail}");
3405    }
3406
3407    const WORKER_BINARY_OVERRIDE_CHILD: &str = "MJ_WORKER_BINARY_OVERRIDE_CHILD";
3408
3409    /// The override names a worker outright, so it does not care where the
3410    /// controller lives or whether that path still exists.
3411    #[test]
3412    fn a_replaced_controller_still_honors_the_worker_binary_override() {
3413        // MJ_WORKER_BINARY is process-global and other tests resolve worker
3414        // binaries, so set it only in an exact child test.
3415        if std::env::var_os(WORKER_BINARY_OVERRIDE_CHILD).is_none() {
3416            let directory = tempfile::tempdir().unwrap();
3417            let worker = directory.path().join("mj-worker");
3418            std::fs::write(&worker, b"worker").unwrap();
3419            let test_name = format!(
3420                "{}::a_replaced_controller_still_honors_the_worker_binary_override",
3421                module_path!()
3422                    .strip_prefix("mj_controller::")
3423                    .unwrap_or(module_path!())
3424            );
3425            let output = std::process::Command::new(std::env::current_exe().unwrap())
3426                .args(["--exact", &test_name, "--nocapture"])
3427                .env(WORKER_BINARY_OVERRIDE_CHILD, "1")
3428                .env("MJ_WORKER_BINARY", &worker)
3429                .output()
3430                .unwrap();
3431            assert!(
3432                output.status.success(),
3433                "isolated worker override test failed\nstdout:\n{}\nstderr:\n{}",
3434                String::from_utf8_lossy(&output.stdout),
3435                String::from_utf8_lossy(&output.stderr)
3436            );
3437            return;
3438        }
3439
3440        let stale = PathBuf::from("/src/.backup-vHXvCs/target/debug/mj (deleted)");
3441        let availability = worker_binary_prerequisite_for_current(
3442            FOREIGN_ARCH,
3443            WorkerBinaryRequirement::PortableLinux,
3444            &stale,
3445            &|path| path.is_file(),
3446        )
3447        .unwrap();
3448
3449        match availability {
3450            WorkerBinaryAvailability::Local { source, .. } => {
3451                assert_eq!(source, "MJ_WORKER_BINARY");
3452            }
3453            other => panic!("expected the override to resolve, got {other:?}"),
3454        }
3455    }
3456
3457    #[test]
3458    fn sibling_lookup_falls_back_to_the_legacy_hel_name_beside_an_mj_controller() {
3459        let controller = PathBuf::from("/opt/brokk/mj");
3460        let legacy = PathBuf::from("/opt/brokk/hel");
3461        let selected = select_sibling_worker(&controller, "x86_64-unknown-linux-musl", |path| {
3462            path == legacy
3463        });
3464        assert_eq!(selected, Some((legacy, "beside the running executable")));
3465    }
3466
3467    #[test]
3468    fn worker_diagnosis_surfaces_a_loader_failure_from_the_installed_binary() {
3469        struct FailedProbe;
3470
3471        impl CommandExecutor for FailedProbe {
3472            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
3473                Ok(CommandOutput {
3474                    status: 1,
3475                    stdout: Vec::new(),
3476                    stderr: b"libc.so.6: version `GLIBC_2.39' not found\n".to_vec(),
3477                })
3478            }
3479        }
3480
3481        let failure = worker_binary_probe_failure(
3482            &FailedProbe,
3483            &hel_targets::TargetLocator::LocalBare {
3484                worker_root: "/worker/root".into(),
3485            },
3486            "/worker/root",
3487        )
3488        .expect("an unsuccessful --version probe should explain the dead worker");
3489
3490        assert!(failure.contains("GLIBC_2.39"), "{failure}");
3491        assert!(failure.contains("provide a musl worker"), "{failure}");
3492    }
3493
3494    /// A worker that died leaves an exit record behind. Starting a new worker
3495    /// must clear it first, or the startup connect loop reads the previous
3496    /// death as this worker's and gives up on a healthy daemon.
3497    #[test]
3498    fn starting_a_worker_clears_stale_runtime_files_before_launching() {
3499        struct RecordingExecutor {
3500            commands: RefCell<Vec<CommandSpec>>,
3501        }
3502
3503        impl CommandExecutor for RecordingExecutor {
3504            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3505                self.commands.borrow_mut().push(command.clone());
3506                Ok(CommandOutput {
3507                    status: 0,
3508                    stdout: Vec::new(),
3509                    stderr: Vec::new(),
3510                })
3511            }
3512        }
3513
3514        for locator in [
3515            hel_targets::TargetLocator::LocalBare {
3516                worker_root: "/worker/root".into(),
3517            },
3518            hel_targets::TargetLocator::LocalPodman {
3519                container_id: "container-1".into(),
3520                workspace_storage: Default::default(),
3521            },
3522        ] {
3523            let executor = RecordingExecutor {
3524                commands: RefCell::new(Vec::new()),
3525            };
3526            start_worker(&executor, &locator, "/worker/root").unwrap();
3527
3528            let commands = executor.commands.borrow();
3529            let script = commands
3530                .iter()
3531                .flat_map(|command| command.args.iter())
3532                .find(|argument| argument.contains("worker-exit.json"))
3533                .unwrap_or_else(|| {
3534                    panic!("no launch script cleared the exit record: {commands:?}")
3535                });
3536            let cleared = script.find("rm -f").expect("the exit record is removed");
3537            let launched = script.find("worker").expect("the daemon is launched");
3538            assert!(
3539                script.contains("control.sock"),
3540                "the stale relay endpoint must be cleared before startup: {script}"
3541            );
3542            assert!(
3543                cleared < launched,
3544                "stale runtime files must be cleared before the daemon starts: {script}"
3545            );
3546        }
3547    }
3548    #[test]
3549    fn stopping_a_worker_runs_the_daemon_stop_script() {
3550        struct RecordingExecutor {
3551            commands: RefCell<Vec<CommandSpec>>,
3552        }
3553
3554        impl CommandExecutor for RecordingExecutor {
3555            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3556                self.commands.borrow_mut().push(command.clone());
3557                Ok(CommandOutput {
3558                    status: 0,
3559                    stdout: Vec::new(),
3560                    stderr: Vec::new(),
3561                })
3562            }
3563        }
3564
3565        let locator = hel_targets::TargetLocator::SshBare {
3566            ssh: SshTarget {
3567                destination: "user@example.test".into(),
3568                ssh_args: Vec::new(),
3569            },
3570            workspace: "/workspace".into(),
3571        };
3572        let executor = RecordingExecutor {
3573            commands: RefCell::new(Vec::new()),
3574        };
3575        stop_worker(&executor, &locator, "/worker/root").unwrap();
3576
3577        let commands = executor.commands.borrow();
3578        assert_eq!(commands.len(), 1);
3579        assert_eq!(commands[0].purpose, "stop Mjolnir worker daemon");
3580        assert!(
3581            commands[0]
3582                .args
3583                .last()
3584                .is_some_and(|remote| remote.starts_with("'sh' '-c' ")),
3585            "raw SSH worker management must not source login profiles: {commands:?}"
3586        );
3587        let script = commands[0]
3588            .args
3589            .iter()
3590            .find(|argument| argument.contains("worker run --root"))
3591            .unwrap_or_else(|| panic!("stop script missing from {commands:?}"));
3592        assert!(
3593            script.contains("hel_match=\"hel worker run --root $hel_root\""),
3594            "stop must match only this session's worker: {script}"
3595        );
3596        assert!(
3597            script.contains("hel_match_home=\"hel worker run --root $HOME/$hel_root\""),
3598            "stop must also match a login-home-absolute --root: {script}"
3599        );
3600        assert!(
3601            !script.contains("grep -F"),
3602            "leftover detection must not grep the match string: {script}"
3603        );
3604    }
3605    #[test]
3606    fn checkpoint_worker_stop_restores_a_stopped_podman_target_first() {
3607        struct RecordingExecutor {
3608            commands: RefCell<Vec<CommandSpec>>,
3609            outputs: RefCell<Vec<CommandOutput>>,
3610        }
3611
3612        impl CommandExecutor for RecordingExecutor {
3613            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3614                self.commands.borrow_mut().push(command.clone());
3615                Ok(self.outputs.borrow_mut().remove(0))
3616            }
3617        }
3618
3619        let session = "0123456789abcdef0123456789abcdef";
3620        let container_id = hel_targets::resource_name(session).unwrap();
3621        let inspection = |status: &str| CommandOutput {
3622            status: 0,
3623            stdout: serde_json::to_vec(&serde_json::json!([{
3624                "Config": { "Labels": {
3625                    (hel_targets::MANAGED_LABEL): "true",
3626                    (hel_targets::SESSION_LABEL): session,
3627                }},
3628                "State": { "Status": status },
3629            }]))
3630            .unwrap(),
3631            stderr: Vec::new(),
3632        };
3633        let executor = RecordingExecutor {
3634            commands: RefCell::new(Vec::new()),
3635            outputs: RefCell::new(vec![
3636                CommandOutput {
3637                    status: 0,
3638                    stdout: Vec::new(),
3639                    stderr: Vec::new(),
3640                },
3641                inspection("exited"),
3642                CommandOutput {
3643                    status: 0,
3644                    stdout: Vec::new(),
3645                    stderr: Vec::new(),
3646                },
3647                inspection("running"),
3648                CommandOutput {
3649                    status: 0,
3650                    stdout: Vec::new(),
3651                    stderr: Vec::new(),
3652                },
3653            ]),
3654        };
3655        let locator = hel_targets::TargetLocator::LocalPodman {
3656            container_id,
3657            workspace_storage: Default::default(),
3658        };
3659
3660        stop_worker_after_target_recovery(&executor, &locator, session, "/worker/root").unwrap();
3661
3662        let commands = executor.commands.borrow();
3663        let purposes = commands
3664            .iter()
3665            .map(|command| command.purpose.as_str())
3666            .collect::<Vec<_>>();
3667        assert_eq!(
3668            purposes,
3669            [
3670                "check for Mjolnir session container",
3671                "inspect Mjolnir session container",
3672                "start stopped Mjolnir session container",
3673                "inspect Mjolnir session container",
3674                "stop Mjolnir worker daemon",
3675            ]
3676        );
3677    }
3678
3679    struct PodmanInstallExecutor {
3680        commands: RefCell<Vec<CommandSpec>>,
3681        worker_cached: bool,
3682    }
3683    impl CommandExecutor for PodmanInstallExecutor {
3684        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3685            self.commands.borrow_mut().push(command.clone());
3686            let probing_cache = command
3687                .args
3688                .iter()
3689                .any(|argument| argument.contains("'test' '-f'"));
3690            let status = if probing_cache && !self.worker_cached {
3691                1
3692            } else {
3693                0
3694            };
3695            Ok(CommandOutput {
3696                status,
3697                stdout: Vec::new(),
3698                stderr: Vec::new(),
3699            })
3700        }
3701    }
3702    struct PodmanInstallFixture {
3703        _root: tempfile::TempDir,
3704        worker_binary: PathBuf,
3705        launch_config: PathBuf,
3706        ownership: PathBuf,
3707        profile_stage: PathBuf,
3708        locator: hel_targets::TargetLocator,
3709        digest: String,
3710    }
3711    fn podman_install_fixture() -> PodmanInstallFixture {
3712        let root = tempfile::tempdir().unwrap();
3713        let worker_binary = root.path().join("hel");
3714        std::fs::write(&worker_binary, b"worker-binary-bytes").unwrap();
3715        let launch_config = root.path().join("launch.json");
3716        std::fs::write(&launch_config, b"{}").unwrap();
3717        let ownership = root.path().join("ownership.json");
3718        std::fs::write(&ownership, b"{}").unwrap();
3719        let profile_stage = root.path().join("profile");
3720        std::fs::create_dir_all(&profile_stage).unwrap();
3721        let digest = format!("{:x}", Sha256::digest(b"worker-binary-bytes"));
3722        PodmanInstallFixture {
3723            _root: root,
3724            worker_binary,
3725            launch_config,
3726            ownership,
3727            profile_stage,
3728            locator: hel_targets::TargetLocator::SshPodman {
3729                ssh: SshTarget {
3730                    destination: "user@example.test".into(),
3731                    ssh_args: Vec::new(),
3732                },
3733                container_id: "container-1".into(),
3734                workspace_storage: Default::default(),
3735            },
3736            digest,
3737        }
3738    }
3739    fn run_podman_install(worker_cached: bool) -> (Vec<CommandSpec>, PodmanInstallFixture) {
3740        let fixture = podman_install_fixture();
3741        let executor = PodmanInstallExecutor {
3742            commands: RefCell::new(Vec::new()),
3743            worker_cached,
3744        };
3745        install_worker_files(
3746            &executor,
3747            &fixture.locator,
3748            "0123456789abcdef0123456789abcdef",
3749            "/workspace/.hel/worker",
3750            "/workspace/.hel/profile",
3751            &fixture.worker_binary,
3752            &fixture.launch_config,
3753            &fixture.ownership,
3754            &fixture.profile_stage,
3755        )
3756        .unwrap();
3757        let commands = executor.commands.borrow().clone();
3758        (commands, fixture)
3759    }
3760    fn rendered(commands: &[CommandSpec]) -> Vec<String> {
3761        commands
3762            .iter()
3763            .map(|command| format!("{} {}", command.program, command.args.join(" ")))
3764            .collect()
3765    }
3766    #[test]
3767    fn ssh_podman_install_caches_the_worker_binary_on_a_cache_miss() {
3768        let (commands, fixture) = run_podman_install(false);
3769        let lines = rendered(&commands);
3770        let digest = &fixture.digest;
3771        let cache_dir = format!(".cache/mjolnir/workers/{digest}");
3772        let session = "0123456789abcdef0123456789abcdef";
3773
3774        assert!(
3775            lines
3776                .iter()
3777                .any(|line| line.starts_with("ssh") && line.contains("'test' '-f'")),
3778            "expected a cache probe, got {lines:#?}"
3779        );
3780        assert!(
3781            !lines.iter().any(|line| line.contains('~')),
3782            "remote staging paths must be home-relative: ssh arguments are \
3783                 single-quoted so a tilde stays literal in the remote shell while \
3784                 scp expands it, got {lines:#?}"
3785        );
3786        assert!(
3787            lines.iter().any(|line| line.starts_with("ssh")
3788                && line.contains(&format!("'mkdir' '-p' '{cache_dir}'"))),
3789            "expected the cache directory to be created, got {lines:#?}"
3790        );
3791        let partial = format!("{cache_dir}/hel.partial-{session}");
3792        assert!(
3793            lines.iter().any(|line| line
3794                == &format!(
3795                    "scp {} user@example.test:{partial}",
3796                    fixture.worker_binary.display()
3797                )),
3798            "expected the worker to be uploaded to the partial cache path, got {lines:#?}"
3799        );
3800        assert!(
3801            lines.iter().any(|line| line.starts_with("ssh")
3802                && line.contains(&format!("'mv' '{partial}' '{cache_dir}/hel'"))),
3803            "expected an atomic rename into the cache, got {lines:#?}"
3804        );
3805        assert!(
3806            lines.iter().any(|line| line.contains("'podman' 'cp'")
3807                && line.contains(&format!("'{cache_dir}/hel'"))),
3808            "expected podman cp to read the cached worker, got {lines:#?}"
3809        );
3810        assert!(
3811            !lines.iter().any(|line| line.starts_with("scp")
3812                && line.ends_with(&format!(
3813                    "user@example.test:.cache/mjolnir/uploads/{session}/hel"
3814                ))),
3815            "the worker must not be staged in the per-session upload directory, got {lines:#?}"
3816        );
3817    }
3818    #[test]
3819    fn ssh_podman_install_skips_the_worker_upload_on_a_cache_hit() {
3820        let (commands, fixture) = run_podman_install(true);
3821        let lines = rendered(&commands);
3822        let digest = &fixture.digest;
3823        let cache_dir = format!(".cache/mjolnir/workers/{digest}");
3824        let session = "0123456789abcdef0123456789abcdef";
3825
3826        assert!(
3827            !lines.iter().any(|line| line.starts_with("scp")
3828                && line.contains(&fixture.worker_binary.display().to_string())),
3829            "a cached worker must not be re-uploaded, got {lines:#?}"
3830        );
3831        assert!(
3832            !lines.iter().any(|line| line.contains("'mv'")),
3833            "a cache hit must not rename anything, got {lines:#?}"
3834        );
3835        assert!(
3836            lines.iter().any(|line| line.contains("'podman' 'cp'")
3837                && line.contains(&format!("'{cache_dir}/hel'"))),
3838            "expected podman cp to read the cached worker, got {lines:#?}"
3839        );
3840        for name in ["launch.json", "ownership.json"] {
3841            assert!(
3842                lines.iter().any(|line| line.starts_with("scp")
3843                    && line.ends_with(&format!(
3844                        "user@example.test:.cache/mjolnir/uploads/{session}/{name}"
3845                    ))),
3846                "expected {name} to still be uploaded per session, got {lines:#?}"
3847            );
3848        }
3849    }
3850
3851    #[test]
3852    fn ssh_docker_install_uses_docker_for_remote_container_operations() {
3853        let mut fixture = podman_install_fixture();
3854        fixture.locator = hel_targets::TargetLocator::SshDocker {
3855            ssh: SshTarget {
3856                destination: "user@example.test".into(),
3857                ssh_args: Vec::new(),
3858            },
3859            container_id: "container-1".into(),
3860        };
3861        let executor = PodmanInstallExecutor {
3862            commands: RefCell::new(Vec::new()),
3863            worker_cached: true,
3864        };
3865        install_worker_files(
3866            &executor,
3867            &fixture.locator,
3868            "0123456789abcdef0123456789abcdef",
3869            "/workspace/.hel/worker",
3870            "/workspace/.hel/profile",
3871            &fixture.worker_binary,
3872            &fixture.launch_config,
3873            &fixture.ownership,
3874            &fixture.profile_stage,
3875        )
3876        .unwrap();
3877
3878        let lines = rendered(&executor.commands.borrow());
3879        assert!(
3880            lines.iter().any(|line| line.contains("'docker' 'cp'")),
3881            "expected Docker to copy the cached worker, got {lines:#?}"
3882        );
3883        assert!(
3884            lines.iter().any(|line| line.contains("'docker' 'exec'")),
3885            "expected Docker to prepare the worker directories, got {lines:#?}"
3886        );
3887        assert!(
3888            !lines.iter().any(|line| line.contains("'podman'")),
3889            "Docker installation accidentally used Podman: {lines:#?}"
3890        );
3891    }
3892
3893    #[test]
3894    #[ignore = "requires Docker and the locally installed agent-dev image"]
3895    fn docker_uploads_and_replacements_are_usable_by_the_non_root_worker() {
3896        let fixture = podman_install_fixture();
3897        let session = hel::hel_state::new_session_id().unwrap();
3898        let container_id = hel_targets::resource_name(&session).unwrap();
3899        let locator = hel_targets::TargetLocator::LocalDocker {
3900            container_id: container_id.clone(),
3901        };
3902        execute_checked(
3903            &ProcessExecutor,
3904            CommandSpec::new(
3905                "docker",
3906                [
3907                    "run",
3908                    "--pull=never",
3909                    "-d",
3910                    "--name",
3911                    &container_id,
3912                    "ghcr.io/brokkai/mjolnir/agent-dev:latest",
3913                    "sleep",
3914                    "infinity",
3915                ],
3916            ),
3917        )
3918        .unwrap();
3919        let result = (|| -> Result<()> {
3920            let root = hel_targets::worker_root(&locator, &session)?;
3921            let profile = format!("{root}/profile");
3922            std::fs::write(fixture.profile_stage.join("credential"), "private")?;
3923            install_worker_files(
3924                &ProcessExecutor,
3925                &locator,
3926                &session,
3927                &root,
3928                &profile,
3929                &fixture.worker_binary,
3930                &fixture.launch_config,
3931                &fixture.ownership,
3932                &fixture.profile_stage,
3933            )?;
3934            replace_installed_worker_binary(
3935                &ProcessExecutor,
3936                &locator,
3937                &session,
3938                &fixture.worker_binary,
3939            )?;
3940            execute_checked(
3941                &ProcessExecutor,
3942                CommandSpec::new(
3943                    "docker",
3944                    [
3945                        "exec",
3946                        &container_id,
3947                        "sh",
3948                        "-c",
3949                        "test \"$(id -u)\" != 0 && test -x \"$1/hel\" && test -r \"$1/launch.json\" && test -r \"$1/ownership.json\" && test -r \"$1/profile/credential\" && test -w \"$1/profile/credential\"",
3950                        "sh",
3951                        &root,
3952                    ],
3953                ),
3954            )?;
3955            Ok(())
3956        })();
3957        let cleanup = execute_checked(
3958            &ProcessExecutor,
3959            CommandSpec::new("docker", ["rm", "-f", &container_id]),
3960        );
3961        result.unwrap();
3962        cleanup.unwrap();
3963    }
3964
3965    #[test]
3966    fn replacing_an_installed_podman_worker_writes_through_a_next_path() {
3967        struct RecordingExecutor {
3968            commands: RefCell<Vec<CommandSpec>>,
3969        }
3970        impl CommandExecutor for RecordingExecutor {
3971            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
3972                self.commands.borrow_mut().push(command.clone());
3973                Ok(CommandOutput {
3974                    status: 0,
3975                    stdout: Vec::new(),
3976                    stderr: Vec::new(),
3977                })
3978            }
3979        }
3980
3981        let session = "0123456789abcdef0123456789abcdef";
3982        let container_id = hel_targets::resource_name(session).unwrap();
3983        let locator = hel_targets::TargetLocator::LocalPodman {
3984            container_id: container_id.clone(),
3985            workspace_storage: Default::default(),
3986        };
3987        let executor = RecordingExecutor {
3988            commands: RefCell::new(Vec::new()),
3989        };
3990        replace_installed_worker_binary(&executor, &locator, session, Path::new("/controller/hel"))
3991            .unwrap();
3992
3993        let mut lines = rendered(&executor.commands.borrow());
3994        let ownership = lines.remove(1);
3995        assert!(ownership.starts_with(&format!("podman exec --user 0 {container_id} sh -c")));
3996        assert!(ownership.contains("chown -R"));
3997        assert!(ownership.ends_with(&format!("/var/lib/hel/workers/{session}/hel.next")));
3998        assert_eq!(
3999            lines,
4000            vec![
4001                format!(
4002                    "podman cp /controller/hel {container_id}:/var/lib/hel/workers/{session}/hel.next"
4003                ),
4004                format!(
4005                    "podman exec {container_id} mv -f /var/lib/hel/workers/{session}/hel.next /var/lib/hel/workers/{session}/hel"
4006                ),
4007                format!("podman exec {container_id} chmod 700 /var/lib/hel/workers/{session}/hel"),
4008            ]
4009        );
4010    }
4011    #[test]
4012    fn default_bridges_pin_command_capable_adapter_versions() {
4013        let (codex_command, codex_arguments) = bridge_launch(
4014            hel::hel_config::HarnessKind::Codex,
4015            ExecutionPolicy::Unconstrained,
4016        );
4017        assert_eq!(codex_command, "sh");
4018        assert_eq!(codex_arguments[0], "-c");
4019        assert!(codex_arguments[1].contains("@agentclientprotocol/codex-acp@1.8.0"));
4020        assert!(codex_arguments[1].contains("codex-acp --version"));
4021
4022        let (claude_command, claude_arguments) = bridge_launch(
4023            hel::hel_config::HarnessKind::Claude,
4024            ExecutionPolicy::Unconstrained,
4025        );
4026        assert_eq!(claude_command, "sh");
4027        assert_eq!(claude_arguments[0], "-c");
4028        assert!(claude_arguments[1].contains("@agentclientprotocol/claude-agent-acp@0.73.0"));
4029
4030        let (deepseek_command, deepseek_arguments) = bridge_launch(
4031            hel::hel_config::HarnessKind::Deepseek,
4032            ExecutionPolicy::Unconstrained,
4033        );
4034        assert_eq!(deepseek_command, "sh");
4035        assert_eq!(deepseek_arguments[0], "-c");
4036        assert!(deepseek_arguments[1].contains("@deepseek-ai/dsh@0.1.2-rc.1"));
4037        assert!(deepseek_arguments[1].contains("dsh --profile acp"));
4038        assert!(deepseek_arguments[1].contains("dsh --version"));
4039        assert!(!deepseek_arguments[1].contains("npx -y -p @deepseek-ai/dsh"));
4040        assert!(deepseek_arguments[1].contains("Mjolnir needs @deepseek-ai/dsh"));
4041        assert!(!deepseek_arguments[1].contains("Hel"));
4042    }
4043
4044    #[test]
4045    fn readiness_stage_names_only_install_capable_default_harnesses() {
4046        let profile = |kind| hel::hel_config::HarnessProfile {
4047            enabled: true,
4048            kind,
4049            home: PathBuf::from("/profiles/test"),
4050            environment: BTreeMap::new(),
4051            context_window_bytes: None,
4052        };
4053
4054        for harness in [
4055            HarnessKind::Codex,
4056            HarnessKind::Claude,
4057            HarnessKind::Kimi,
4058            HarnessKind::Grok,
4059        ] {
4060            assert_eq!(
4061                bridge_readiness_stage(&profile(harness)),
4062                ProvisionStage::Installing(harness)
4063            );
4064        }
4065        assert_eq!(
4066            bridge_readiness_stage(&profile(HarnessKind::Deepseek)),
4067            ProvisionStage::Starting
4068        );
4069    }
4070    #[test]
4071    fn codex_execution_environment_is_full_access_under_both_target_policies() {
4072        let mut podman_environment =
4073            BTreeMap::from([("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned())]);
4074        hel::hel_config::HarnessKind::Codex.configure_execution_environment(
4075            ExecutionPolicy::Unconstrained,
4076            &mut podman_environment,
4077        );
4078        assert_eq!(
4079            podman_environment
4080                .get("INITIAL_AGENT_MODE")
4081                .map(String::as_str),
4082            Some("agent-full-access")
4083        );
4084
4085        let mut bare_environment =
4086            BTreeMap::from([("INITIAL_AGENT_MODE".to_owned(), "read-only".to_owned())]);
4087        hel::hel_config::HarnessKind::Codex.configure_execution_environment(
4088            ExecutionPolicy::ConfiguredApprovals,
4089            &mut bare_environment,
4090        );
4091        assert_eq!(
4092            bare_environment
4093                .get("INITIAL_AGENT_MODE")
4094                .map(String::as_str),
4095            Some("agent-full-access"),
4096            "Codex ACP must use full access even on raw localhost"
4097        );
4098    }
4099    #[test]
4100    fn only_raw_targets_without_an_explicit_path_request_login_path_discovery() {
4101        let raw = hel_targets::TargetLocator::LocalBare {
4102            worker_root: "/worker".into(),
4103        };
4104        let managed = hel_targets::TargetLocator::LocalPodman {
4105            container_id: "container".into(),
4106            workspace_storage: Default::default(),
4107        };
4108
4109        let mut environment = BTreeMap::new();
4110        configure_login_path_discovery(&mut environment, &raw);
4111        assert_eq!(
4112            environment.get(DISCOVER_LOGIN_PATH_ENV).map(String::as_str),
4113            Some("1")
4114        );
4115
4116        let mut explicit = BTreeMap::from([
4117            ("PATH".into(), "/configured/bin".into()),
4118            (DISCOVER_LOGIN_PATH_ENV.into(), "stale".into()),
4119        ]);
4120        configure_login_path_discovery(&mut explicit, &raw);
4121        assert_eq!(
4122            explicit.get("PATH").map(String::as_str),
4123            Some("/configured/bin")
4124        );
4125        assert!(!explicit.contains_key(DISCOVER_LOGIN_PATH_ENV));
4126
4127        let mut managed_environment =
4128            BTreeMap::from([(DISCOVER_LOGIN_PATH_ENV.into(), "stale".into())]);
4129        configure_login_path_discovery(&mut managed_environment, &managed);
4130        assert!(!managed_environment.contains_key(DISCOVER_LOGIN_PATH_ENV));
4131    }
4132    #[test]
4133    fn bare_targets_use_managed_harnesses_but_containers_stay_ambient() {
4134        let ssh = SshTarget {
4135            destination: "user@example.test".into(),
4136            ssh_args: Vec::new(),
4137        };
4138        let targets = [
4139            (
4140                hel_targets::TargetLocator::LocalBare {
4141                    worker_root: "/worker".into(),
4142                },
4143                HarnessRuntimePolicy::Managed,
4144            ),
4145            (
4146                hel_targets::TargetLocator::LocalPodman {
4147                    container_id: "container".into(),
4148                    workspace_storage: Default::default(),
4149                },
4150                HarnessRuntimePolicy::Ambient,
4151            ),
4152            (
4153                hel_targets::TargetLocator::SshBare {
4154                    ssh: ssh.clone(),
4155                    workspace: "/workspace/session".into(),
4156                },
4157                HarnessRuntimePolicy::Managed,
4158            ),
4159            (
4160                hel_targets::TargetLocator::AwsEc2 {
4161                    profile: "profile".into(),
4162                    region: "us-east-1".into(),
4163                    instance_id: "i-test".into(),
4164                    ssh,
4165                    workspace: "/workspace/session".into(),
4166                },
4167                HarnessRuntimePolicy::Managed,
4168            ),
4169        ];
4170
4171        for (target, expected) in targets {
4172            assert_eq!(harness_runtime_policy(&target), expected, "{target:?}");
4173        }
4174    }
4175    #[test]
4176    fn grok_sandbox_environment_follows_the_target_policy() {
4177        let mut isolated = BTreeMap::from([("GROK_SANDBOX".to_owned(), "strict".to_owned())]);
4178        hel::hel_config::HarnessKind::Grok
4179            .configure_execution_environment(ExecutionPolicy::Unconstrained, &mut isolated);
4180        assert_eq!(
4181            isolated.get("GROK_SANDBOX").map(String::as_str),
4182            Some("off")
4183        );
4184
4185        let mut local = BTreeMap::from([("GROK_SANDBOX".to_owned(), "strict".to_owned())]);
4186        hel::hel_config::HarnessKind::Grok
4187            .configure_execution_environment(ExecutionPolicy::ConfiguredApprovals, &mut local);
4188        assert_eq!(
4189            local.get("GROK_SANDBOX").map(String::as_str),
4190            Some("strict"),
4191            "raw localhost must preserve the profile's configured sandbox"
4192        );
4193    }
4194    #[test]
4195    fn bridge_fallback_pins_match_the_agent_dev_containerfile() {
4196        const CONTAINERFILE: &str = include_str!("../../../containers/Containerfile.agent-dev");
4197
4198        let codex = format!("codex-acp@{CODEX_ACP_VERSION}");
4199        assert!(
4200            CONTAINERFILE.contains(&codex),
4201            "containers/Containerfile.agent-dev must install {codex}. The image and the \
4202                 bridge_launch() npx fallbacks have to stay in lockstep, otherwise a container \
4203                 session and an npx session run different adapter versions."
4204        );
4205
4206        let claude = format!("claude-agent-acp@{CLAUDE_ACP_VERSION}");
4207        assert!(
4208            CONTAINERFILE.contains(&claude),
4209            "containers/Containerfile.agent-dev must install {claude}. The image and the \
4210                 bridge_launch() npx fallbacks have to stay in lockstep, otherwise a container \
4211                 session and an npx session run different adapter versions."
4212        );
4213
4214        let deepseek = format!("@deepseek-ai/dsh@{DEEPSEEK_DSH_VERSION}");
4215        assert!(
4216            CONTAINERFILE.contains(&deepseek),
4217            "containers/Containerfile.agent-dev must install {deepseek}"
4218        );
4219        assert!(!CONTAINERFILE.contains("dsh-acp-server"));
4220    }
4221    #[test]
4222    fn kimi_default_bridge_is_non_login_and_uses_bash_for_the_official_installer() {
4223        let (command, arguments) = bridge_launch(
4224            hel::hel_config::HarnessKind::Kimi,
4225            ExecutionPolicy::Unconstrained,
4226        );
4227        assert_eq!(command, "sh");
4228        assert_eq!(arguments[0], "-c");
4229        assert!(arguments[1].contains("install.sh | bash &&"));
4230        assert!(arguments[1].contains("$HOME/.kimi-code/bin/kimi"));
4231        assert!(arguments[1].contains("Mjolnir needs compatible Kimi Code"));
4232        assert!(!arguments[1].contains("Hel"));
4233    }
4234    #[test]
4235    fn grok_default_bridge_is_non_login_and_uses_bash_for_the_official_installer() {
4236        let (command, arguments) = bridge_launch(
4237            hel::hel_config::HarnessKind::Grok,
4238            ExecutionPolicy::ConfiguredApprovals,
4239        );
4240        assert_eq!(command, "sh");
4241        assert_eq!(arguments[0], "-c");
4242        let script = &arguments[1];
4243        assert!(script.contains("https://x.ai/cli/install.sh | bash &&"));
4244        assert!(script.contains("command -v grok"));
4245        assert!(script.contains("[ -x \"$GROK_HOME/bin/grok\" ]"));
4246        assert!(script.contains("[ -x \"$HOME/.grok/bin/grok\" ]"));
4247        assert!(script.contains("exit 127"));
4248        assert!(script.contains("exec grok agent stdio"));
4249        assert!(!script.contains("--always-approve"));
4250        assert!(script.contains("Mjolnir needs compatible Grok Build"));
4251        assert!(!script.contains("Hel"));
4252    }
4253    #[test]
4254    fn node_bootstrap_errors_name_mjolnir() {
4255        let script = ensure_node_script();
4256        assert!(script.contains("Mjolnir needs Node.js, npm, and npx"));
4257        assert!(!script.contains("sudo"));
4258        assert!(!script.contains("apt-get"));
4259        assert!(!script.contains("Hel"));
4260    }
4261    #[test]
4262    fn grok_default_bridge_adds_the_always_approve_flag_when_unrestricted() {
4263        let (_, arguments) = bridge_launch(
4264            hel::hel_config::HarnessKind::Grok,
4265            ExecutionPolicy::Unconstrained,
4266        );
4267        let script = &arguments[1];
4268        assert!(script.contains("exec grok agent --always-approve stdio"));
4269        assert!(script.contains("exec \"$GROK_HOME/bin/grok\" agent --always-approve stdio"));
4270        assert!(script.contains("exec \"$HOME/.grok/bin/grok\" agent --always-approve stdio"));
4271    }
4272    #[test]
4273    fn kimi_uses_runtime_aware_memory_delivery_only_on_staged_targets() {
4274        let local = hel_targets::TargetLocator::LocalBare {
4275            worker_root: "/worker".into(),
4276        };
4277        let podman = hel_targets::TargetLocator::LocalPodman {
4278            container_id: "container".into(),
4279            workspace_storage: Default::default(),
4280        };
4281
4282        assert_eq!(
4283            project_memory_mcp_delivery(hel::hel_config::HarnessKind::Kimi, &local),
4284            ProjectMemoryMcpDelivery::Acp
4285        );
4286        assert_eq!(
4287            project_memory_mcp_delivery(hel::hel_config::HarnessKind::Kimi, &podman),
4288            ProjectMemoryMcpDelivery::HarnessProfile
4289        );
4290        assert_eq!(
4291            project_memory_mcp_delivery(hel::hel_config::HarnessKind::Codex, &podman),
4292            ProjectMemoryMcpDelivery::Acp
4293        );
4294    }
4295    #[test]
4296    fn stage_grok_profile_copies_authentication_and_agent_identity() {
4297        let home = tempfile::tempdir().unwrap();
4298        std::fs::write(
4299            home.path().join("auth.json"),
4300            "{\"https://auth.x.ai::1\":{}}",
4301        )
4302        .unwrap();
4303        std::fs::write(home.path().join("agent_id"), "stable-agent-id").unwrap();
4304        std::fs::write(home.path().join("config.toml"), "model = \"grok-4.6\"\n").unwrap();
4305        // Native session storage is checkpointed, never staged.
4306        std::fs::create_dir(home.path().join("sessions")).unwrap();
4307        std::fs::write(home.path().join("sessions/session_search.sqlite"), "x").unwrap();
4308        let staged = tempfile::tempdir().unwrap();
4309        let profile = hel::hel_config::HarnessProfile {
4310            enabled: true,
4311            kind: hel::hel_config::HarnessKind::Grok,
4312            home: home.path().to_path_buf(),
4313            environment: BTreeMap::new(),
4314            context_window_bytes: None,
4315        };
4316
4317        stage_profile(&profile, staged.path()).unwrap();
4318
4319        assert_eq!(
4320            std::fs::read_to_string(staged.path().join("agent_id")).unwrap(),
4321            "stable-agent-id"
4322        );
4323        assert!(staged.path().join("auth.json").is_file());
4324        assert!(staged.path().join("config.toml").is_file());
4325        assert!(!staged.path().join("sessions").exists());
4326    }
4327    #[test]
4328    fn stage_claude_profile_preserves_rollout_identity() {
4329        let home = tempfile::tempdir().unwrap();
4330        let identity = r#"{
4331                "machineID": "stable-machine",
4332                "userID": "stable-user",
4333                "cachedGrowthBookFeatures": {
4334                    "tengu_velvet_mallet_fable_5": true
4335                }
4336            }"#;
4337        std::fs::write(home.path().join(".claude.json"), identity).unwrap();
4338        let staged = tempfile::tempdir().unwrap();
4339        let profile = hel::hel_config::HarnessProfile {
4340            enabled: true,
4341            kind: hel::hel_config::HarnessKind::Claude,
4342            home: home.path().to_path_buf(),
4343            environment: BTreeMap::new(),
4344            context_window_bytes: None,
4345        };
4346
4347        stage_profile(&profile, staged.path()).unwrap();
4348
4349        assert_eq!(
4350            std::fs::read_to_string(staged.path().join(".claude.json")).unwrap(),
4351            identity
4352        );
4353    }
4354    #[test]
4355    fn stage_kimi_profile_preserves_device_identity() {
4356        let home = tempfile::tempdir().unwrap();
4357        std::fs::write(home.path().join("config.toml"), "default_model = \"k3\"\n").unwrap();
4358        std::fs::write(home.path().join("device_id"), "stable-device-id").unwrap();
4359        std::fs::create_dir(home.path().join("credentials")).unwrap();
4360        std::fs::write(
4361            home.path().join("credentials/kimi-code.json"),
4362            "{\"access_token\":\"secret\"}",
4363        )
4364        .unwrap();
4365        let staged = tempfile::tempdir().unwrap();
4366        let profile = hel::hel_config::HarnessProfile {
4367            enabled: true,
4368            kind: hel::hel_config::HarnessKind::Kimi,
4369            home: home.path().to_path_buf(),
4370            environment: BTreeMap::new(),
4371            context_window_bytes: None,
4372        };
4373
4374        stage_profile(&profile, staged.path()).unwrap();
4375
4376        assert_eq!(
4377            std::fs::read_to_string(staged.path().join("device_id")).unwrap(),
4378            "stable-device-id"
4379        );
4380        assert!(staged.path().join("credentials/kimi-code.json").is_file());
4381    }
4382    #[test]
4383    fn staged_kimi_profile_binds_project_memory_to_the_target_runtime() {
4384        let home = tempfile::tempdir().unwrap();
4385        let original = serde_json::json!({
4386            "mcpServers": {
4387                "user-server": {
4388                    "command": "user-mcp",
4389                    "args": ["serve"]
4390                }
4391            },
4392            "userSetting": true
4393        });
4394        let original_body = serde_json::to_vec_pretty(&original).unwrap();
4395        std::fs::write(home.path().join("mcp.json"), &original_body).unwrap();
4396        let staged = tempfile::tempdir().unwrap();
4397        let profile = hel::hel_config::HarnessProfile {
4398            enabled: true,
4399            kind: hel::hel_config::HarnessKind::Kimi,
4400            home: home.path().to_path_buf(),
4401            environment: BTreeMap::new(),
4402            context_window_bytes: None,
4403        };
4404        stage_profile(&profile, staged.path()).unwrap();
4405        let memory = ProjectMemoryLaunchConfig {
4406            project_key: "project".into(),
4407            root: "/var/lib/hel/profiles/session/projects/project/memory".into(),
4408            baseline_root: PathBuf::new(),
4409            repository_roots: BTreeMap::new(),
4410            mcp_delivery: ProjectMemoryMcpDelivery::HarnessProfile,
4411        };
4412
4413        configure_kimi_project_memory_mcp(staged.path(), "/var/lib/hel/workers/session", &memory)
4414            .unwrap();
4415
4416        let configured: serde_json::Value =
4417            serde_json::from_slice(&std::fs::read(staged.path().join("mcp.json")).unwrap())
4418                .unwrap();
4419        assert_eq!(configured["userSetting"], true);
4420        assert_eq!(
4421            configured["mcpServers"]["user-server"]["command"],
4422            "user-mcp"
4423        );
4424        assert_eq!(
4425            configured["mcpServers"]["mj-project-memory"],
4426            serde_json::json!({
4427                "transport": "stdio",
4428                "command": "/var/lib/hel/workers/session/hel",
4429                "args": [
4430                    "worker",
4431                    "memory-mcp",
4432                    "--root",
4433                    "/var/lib/hel/profiles/session/projects/project/memory"
4434                ],
4435                "runtime_id": "local"
4436            })
4437        );
4438        assert_eq!(
4439            std::fs::read(home.path().join("mcp.json")).unwrap(),
4440            original_body,
4441            "the controller-side Kimi profile must remain unchanged"
4442        );
4443    }
4444
4445    #[test]
4446    fn staged_kimi_project_memory_resolves_ssh_paths_from_target_home() {
4447        let staged = tempfile::tempdir().unwrap();
4448        let memory = ProjectMemoryLaunchConfig {
4449            project_key: "project".into(),
4450            root: ".local/share/hel/profiles/session/projects/project/memory".into(),
4451            baseline_root: PathBuf::new(),
4452            repository_roots: BTreeMap::new(),
4453            mcp_delivery: ProjectMemoryMcpDelivery::HarnessProfile,
4454        };
4455
4456        configure_kimi_project_memory_mcp(
4457            staged.path(),
4458            ".local/share/hel/workers/session",
4459            &memory,
4460        )
4461        .unwrap();
4462
4463        let configured: serde_json::Value =
4464            serde_json::from_slice(&std::fs::read(staged.path().join("mcp.json")).unwrap())
4465                .unwrap();
4466        let server = &configured["mcpServers"]["mj-project-memory"];
4467        assert_eq!(server["command"], "sh");
4468        assert_eq!(server["runtime_id"], "local");
4469        assert_eq!(
4470            server["args"],
4471            serde_json::json!([
4472                "-c",
4473                "exec \"$HOME/$1\" worker memory-mcp --root \"$HOME/$2\"",
4474                "mj-project-memory",
4475                ".local/share/hel/workers/session/hel",
4476                ".local/share/hel/profiles/session/projects/project/memory"
4477            ])
4478        );
4479    }
4480    #[test]
4481    fn stage_deepseek_profile_copies_only_portable_configuration() {
4482        let home = tempfile::tempdir().unwrap();
4483        std::fs::write(
4484            home.path().join(".credentials.yaml"),
4485            "version: 1\nrefs: {}\n",
4486        )
4487        .unwrap();
4488        std::fs::write(home.path().join("settings.yaml"), "models: {}\n").unwrap();
4489        std::fs::create_dir(home.path().join("sessions")).unwrap();
4490        std::fs::write(home.path().join("sessions/native-session"), "private state").unwrap();
4491        std::fs::create_dir(home.path().join("profiles")).unwrap();
4492        let staged = tempfile::tempdir().unwrap();
4493        let profile = hel::hel_config::HarnessProfile {
4494            enabled: true,
4495            kind: hel::hel_config::HarnessKind::Deepseek,
4496            home: home.path().to_path_buf(),
4497            environment: BTreeMap::new(),
4498            context_window_bytes: None,
4499        };
4500
4501        stage_profile(&profile, staged.path()).unwrap();
4502
4503        assert!(staged.path().join(".credentials.yaml").is_file());
4504        assert!(staged.path().join("settings.yaml").is_file());
4505        assert!(!staged.path().join("sessions").exists());
4506        assert!(!staged.path().join("profiles").exists());
4507    }
4508    #[test]
4509    fn disposable_container_guidance_reaches_each_harness_without_touching_home() {
4510        let target = hel_targets::TargetLocator::LocalPodman {
4511            container_id: "container".into(),
4512            workspace_storage: Default::default(),
4513        };
4514        for (kind, instructions) in [
4515            (hel::hel_config::HarnessKind::Codex, "AGENTS.md"),
4516            (hel::hel_config::HarnessKind::Claude, "CLAUDE.md"),
4517            (hel::hel_config::HarnessKind::Kimi, "AGENTS.md"),
4518            (hel::hel_config::HarnessKind::Grok, "AGENTS.md"),
4519            (hel::hel_config::HarnessKind::Deepseek, "AGENTS.md"),
4520            (hel::hel_config::HarnessKind::Muse, "AGENTS.md"),
4521        ] {
4522            let home = tempfile::tempdir().unwrap();
4523            let original = "# Controller instructions\n\nKeep this source unchanged.\n";
4524            let source_instructions = home.path().join(instructions);
4525            std::fs::write(&source_instructions, original).unwrap();
4526            let staged = tempfile::tempdir().unwrap();
4527            let profile = hel::hel_config::HarnessProfile {
4528                enabled: true,
4529                kind,
4530                home: home.path().to_path_buf(),
4531                environment: std::collections::BTreeMap::new(),
4532                context_window_bytes: None,
4533            };
4534
4535            stage_profile(&profile, staged.path()).unwrap();
4536            append_hel_target_environment(kind, staged.path(), &target).unwrap();
4537
4538            let guidance = std::fs::read_to_string(staged.path().join(instructions)).unwrap();
4539            assert_eq!(
4540                guidance,
4541                format!("{original}\n{MJ_CONTAINER_ENVIRONMENT}"),
4542                "{instructions} receives the section in the staged profile"
4543            );
4544            assert!(guidance.contains("## Mjolnir disposable environment"));
4545            assert!(!guidance.contains("## Hel disposable environment"));
4546            assert_eq!(
4547                std::fs::read_to_string(source_instructions).unwrap(),
4548                original,
4549                "{instructions} in the controller-side home stays untouched"
4550            );
4551        }
4552    }
4553    #[test]
4554    fn kimi_guidance_uses_agents_md_without_mutating_the_system_override() {
4555        let home = tempfile::tempdir().unwrap();
4556        let system_override = "# Custom Kimi system prompt\n";
4557        std::fs::write(home.path().join("SYSTEM.md"), system_override).unwrap();
4558        let staged = tempfile::tempdir().unwrap();
4559        let profile = hel::hel_config::HarnessProfile {
4560            enabled: true,
4561            kind: hel::hel_config::HarnessKind::Kimi,
4562            home: home.path().to_path_buf(),
4563            environment: std::collections::BTreeMap::new(),
4564            context_window_bytes: None,
4565        };
4566
4567        stage_profile(&profile, staged.path()).unwrap();
4568        append_hel_target_environment(
4569            profile.kind,
4570            staged.path(),
4571            &hel_targets::TargetLocator::LocalPodman {
4572                container_id: "container".into(),
4573                workspace_storage: Default::default(),
4574            },
4575        )
4576        .unwrap();
4577
4578        assert_eq!(
4579            std::fs::read_to_string(staged.path().join("AGENTS.md")).unwrap(),
4580            MJ_CONTAINER_ENVIRONMENT
4581        );
4582        assert_eq!(
4583            std::fs::read_to_string(staged.path().join("SYSTEM.md")).unwrap(),
4584            system_override
4585        );
4586        assert!(!home.path().join("AGENTS.md").exists());
4587        assert_eq!(
4588            std::fs::read_to_string(home.path().join("SYSTEM.md")).unwrap(),
4589            system_override
4590        );
4591    }
4592
4593    #[test]
4594    fn ec2_guidance_names_its_real_workspace_and_ssh_bare_gets_none() {
4595        let ec2 = tempfile::tempdir().unwrap();
4596        append_hel_target_environment(
4597            hel::hel_config::HarnessKind::Codex,
4598            ec2.path(),
4599            &hel_targets::TargetLocator::AwsEc2 {
4600                profile: "profile".into(),
4601                region: "region".into(),
4602                instance_id: "instance".into(),
4603                ssh: hel_targets::SshTarget {
4604                    destination: "host".into(),
4605                    ssh_args: Vec::new(),
4606                },
4607                workspace: ".local/share/hel/workspaces/session".into(),
4608            },
4609        )
4610        .unwrap();
4611        let guidance = std::fs::read_to_string(ec2.path().join("AGENTS.md")).unwrap();
4612        assert_eq!(
4613            guidance,
4614            "## 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"
4615        );
4616        assert!(!guidance.contains("## Hel disposable environment"));
4617
4618        let ssh_bare = tempfile::tempdir().unwrap();
4619        append_hel_target_environment(
4620            hel::hel_config::HarnessKind::Codex,
4621            ssh_bare.path(),
4622            &hel_targets::TargetLocator::SshBare {
4623                ssh: hel_targets::SshTarget {
4624                    destination: "host".into(),
4625                    ssh_args: Vec::new(),
4626                },
4627                workspace: ".local/share/hel/workspaces/session".into(),
4628            },
4629        )
4630        .unwrap();
4631        assert!(!ssh_bare.path().join("AGENTS.md").exists());
4632    }
4633
4634    #[test]
4635    fn project_memory_replicas_are_session_private() {
4636        let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
4637        assert_eq!(
4638            project_memory_replica_slug(key, "session-a"),
4639            "hel-0123456789abcdef-session-a"
4640        );
4641        assert_ne!(
4642            project_memory_replica_slug(key, "session-a"),
4643            project_memory_replica_slug(key, "session-b")
4644        );
4645    }
4646
4647    /// Returns a fixed digest line for every command and records what it ran,
4648    /// so a remote refresh can be driven without a real ssh host.
4649    struct DigestExecutor {
4650        installed_line: String,
4651        commands: RefCell<Vec<CommandSpec>>,
4652    }
4653
4654    impl CommandExecutor for DigestExecutor {
4655        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4656            self.commands.borrow_mut().push(command.clone());
4657            Ok(CommandOutput {
4658                status: 0,
4659                stdout: self.installed_line.clone().into_bytes(),
4660                stderr: Vec::new(),
4661            })
4662        }
4663    }
4664
4665    // The SshBare worker_root guard requires the workspace to end in the exact
4666    // session ID, so build the locator around the session under test.
4667    fn ssh_bare_locator(session_id: &str) -> hel_targets::TargetLocator {
4668        hel_targets::TargetLocator::SshBare {
4669            ssh: SshTarget {
4670                destination: "user@host.test".into(),
4671                ssh_args: Vec::new(),
4672            },
4673            workspace: format!("/srv/mj/{session_id}"),
4674        }
4675    }
4676
4677    #[test]
4678    fn remote_upgrade_prepares_managed_harness_without_touching_running_worker() {
4679        let session = "session-remote";
4680        let executor = DigestExecutor {
4681            installed_line: String::new(),
4682            commands: RefCell::new(Vec::new()),
4683        };
4684        let launch = WorkerLaunchConfig {
4685            session_id: session.into(),
4686            harness: HarnessKind::Codex,
4687            bridge_command: "ignored".into(),
4688            bridge_args: Vec::new(),
4689            harness_runtime: HarnessRuntimePolicy::Managed,
4690            environment: BTreeMap::new(),
4691            cwd: "/srv/mj/session-remote/project".into(),
4692            additional_directories: Vec::new(),
4693            native_session_id: None,
4694            project_memory: None,
4695            execution_policy: ExecutionPolicy::ConfiguredApprovals,
4696        };
4697
4698        prepare_managed_harness_for_upgrade(
4699            &executor,
4700            &ssh_bare_locator(session),
4701            session,
4702            Path::new("/controller/hel"),
4703            &launch,
4704        )
4705        .unwrap();
4706
4707        let commands = executor.commands.borrow();
4708        let purposes = commands
4709            .iter()
4710            .map(|command| command.purpose.as_str())
4711            .collect::<Vec<_>>();
4712        assert_eq!(
4713            purposes,
4714            vec![
4715                "clear managed harness preparation staging",
4716                "create managed harness preparation staging",
4717                "stage current worker for managed harness preparation",
4718                "stage managed harness launch configuration",
4719                "make managed harness preparation worker executable",
4720                "prepare exact managed harness",
4721                "remove managed harness preparation staging",
4722            ]
4723        );
4724        assert!(commands.iter().all(|command| {
4725            !command.purpose.contains("stop Mjolnir worker")
4726                && !command.purpose.contains("start Mjolnir worker")
4727                && !command
4728                    .purpose
4729                    .contains("install the current Mjolnir worker binary")
4730        }));
4731        let prepare = commands
4732            .iter()
4733            .find(|command| command.purpose == "prepare exact managed harness")
4734            .unwrap();
4735        let rendered = format!("{} {}", prepare.program, prepare.args.join(" "));
4736        assert!(rendered.contains("worker' 'prepare-harness' '--config'"));
4737    }
4738
4739    #[test]
4740    fn local_upgrade_preflight_uses_current_binary_and_preserves_launch_policy() {
4741        struct ConfigRecordingExecutor {
4742            command: RefCell<Option<CommandSpec>>,
4743            launch: RefCell<Option<WorkerLaunchConfig>>,
4744        }
4745
4746        impl CommandExecutor for ConfigRecordingExecutor {
4747            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4748                let config_path = command
4749                    .args
4750                    .get(3)
4751                    .context("local prepare command did not include its config path")?;
4752                *self.command.borrow_mut() = Some(command.clone());
4753                *self.launch.borrow_mut() = Some(WorkerLaunchConfig::read(Path::new(config_path))?);
4754                Ok(CommandOutput {
4755                    status: 0,
4756                    stdout: Vec::new(),
4757                    stderr: Vec::new(),
4758                })
4759            }
4760        }
4761
4762        struct FailingExecutor {
4763            purposes: RefCell<Vec<String>>,
4764        }
4765
4766        impl CommandExecutor for FailingExecutor {
4767            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
4768                self.purposes.borrow_mut().push(command.purpose.clone());
4769                Err(anyhow::anyhow!("managed harness installation failed"))
4770            }
4771        }
4772
4773        let executor = ConfigRecordingExecutor {
4774            command: RefCell::new(None),
4775            launch: RefCell::new(None),
4776        };
4777        let launch = WorkerLaunchConfig {
4778            session_id: "session-local".into(),
4779            harness: HarnessKind::Codex,
4780            bridge_command: "ignored".into(),
4781            bridge_args: Vec::new(),
4782            harness_runtime: HarnessRuntimePolicy::Managed,
4783            environment: BTreeMap::from([("CODEX_HOME".into(), "/configured/profile/home".into())]),
4784            cwd: "/workspace/project".into(),
4785            additional_directories: Vec::new(),
4786            native_session_id: None,
4787            project_memory: None,
4788            execution_policy: ExecutionPolicy::ConfiguredApprovals,
4789        };
4790        let locator = hel_targets::TargetLocator::LocalBare {
4791            worker_root: "/worker/session-local".into(),
4792        };
4793
4794        prepare_managed_harness_for_upgrade(
4795            &executor,
4796            &locator,
4797            "session-local",
4798            Path::new("/controller/hel"),
4799            &launch,
4800        )
4801        .unwrap();
4802
4803        {
4804            let command = executor.command.borrow();
4805            let command = command.as_ref().unwrap();
4806            assert_eq!(command.purpose, "prepare exact managed harness");
4807            assert_eq!(command.program, "/controller/hel");
4808            assert_eq!(
4809                &command.args[..3],
4810                ["worker", "prepare-harness", "--config"]
4811            );
4812            assert!(!command.args[3].contains("/worker/session-local"));
4813        }
4814
4815        let prepared = executor.launch.borrow();
4816        let prepared = prepared.as_ref().unwrap();
4817        assert_eq!(
4818            prepared.environment.get("CODEX_HOME").map(String::as_str),
4819            Some("/configured/profile/home")
4820        );
4821        assert_eq!(
4822            prepared.execution_policy,
4823            ExecutionPolicy::ConfiguredApprovals
4824        );
4825
4826        let failing = FailingExecutor {
4827            purposes: RefCell::new(Vec::new()),
4828        };
4829        let error = prepare_managed_harness_for_upgrade(
4830            &failing,
4831            &locator,
4832            "session-local",
4833            Path::new("/controller/hel"),
4834            &launch,
4835        )
4836        .unwrap_err();
4837        assert!(
4838            error
4839                .to_string()
4840                .contains("managed harness installation failed")
4841        );
4842        assert_eq!(
4843            failing.purposes.borrow().as_slice(),
4844            ["prepare exact managed harness"]
4845        );
4846    }
4847
4848    #[test]
4849    fn initial_bare_provision_prepares_the_harness_from_installed_files() {
4850        let session = "session-remote";
4851        let executor = DigestExecutor {
4852            installed_line: String::new(),
4853            commands: RefCell::new(Vec::new()),
4854        };
4855        let mut launch = WorkerLaunchConfig {
4856            session_id: session.into(),
4857            harness: HarnessKind::Kimi,
4858            bridge_command: "ignored".into(),
4859            bridge_args: Vec::new(),
4860            harness_runtime: HarnessRuntimePolicy::Managed,
4861            environment: BTreeMap::new(),
4862            cwd: "/srv/mj/session-remote/project".into(),
4863            additional_directories: Vec::new(),
4864            native_session_id: None,
4865            project_memory: None,
4866            execution_policy: ExecutionPolicy::ConfiguredApprovals,
4867        };
4868
4869        let locator = ssh_bare_locator(session);
4870        prepare_installed_managed_harness(&executor, &locator, "/worker/root", &launch).unwrap();
4871        let commands = executor.commands.borrow();
4872        assert_eq!(commands.len(), 1);
4873        assert_eq!(
4874            commands[0].purpose,
4875            "prepare exact managed harness before worker startup"
4876        );
4877        let rendered = format!("{} {}", commands[0].program, commands[0].args.join(" "));
4878        assert!(rendered.contains("'/worker/root/hel' 'worker' 'prepare-harness'"));
4879        drop(commands);
4880
4881        let local = hel_targets::TargetLocator::LocalBare {
4882            worker_root: "/worker/session-remote".into(),
4883        };
4884        prepare_installed_managed_harness(&executor, &local, "/worker/session-remote", &launch)
4885            .unwrap();
4886        let commands = executor.commands.borrow();
4887        assert_eq!(commands.len(), 2);
4888        assert_eq!(commands[1].program, "/worker/session-remote/hel");
4889        assert_eq!(
4890            commands[1].args,
4891            vec![
4892                "worker".to_owned(),
4893                "prepare-harness".to_owned(),
4894                "--config".to_owned(),
4895                "/worker/session-remote/launch.json".to_owned(),
4896            ]
4897        );
4898        drop(commands);
4899
4900        launch.harness_runtime = HarnessRuntimePolicy::Ambient;
4901        prepare_installed_managed_harness(&executor, &locator, "/worker/root", &launch).unwrap();
4902        assert_eq!(executor.commands.borrow().len(), 2);
4903    }
4904
4905    #[test]
4906    fn a_remote_worker_with_a_mismatched_binary_is_replaced_before_restart() {
4907        let directory = tempfile::tempdir().unwrap();
4908        let source = directory.path().join("worker");
4909        std::fs::write(&source, b"fresh musl worker").unwrap();
4910        let executor = DigestExecutor {
4911            installed_line: format!("{}  /root/hel\n", "0".repeat(64)),
4912            commands: RefCell::new(Vec::new()),
4913        };
4914        let replaced = replace_remote_worker_binary_if_stale(
4915            &executor,
4916            &ssh_bare_locator("session-remote"),
4917            "session-remote",
4918            &CommandSpec::new("true", Vec::<String>::new()),
4919            &source,
4920        )
4921        .unwrap();
4922        assert!(replaced, "a stale remote binary must be replaced");
4923        assert!(
4924            executor.commands.borrow().len() > 1,
4925            "the digest probe must be followed by replacement commands"
4926        );
4927    }
4928
4929    #[test]
4930    fn a_remote_worker_already_current_is_restarted_without_recopying() {
4931        let directory = tempfile::tempdir().unwrap();
4932        let source = directory.path().join("worker");
4933        std::fs::write(&source, b"fresh musl worker").unwrap();
4934        let current = hel::hel_worker_launch::worker_executable_digest(&source).unwrap();
4935        let executor = DigestExecutor {
4936            installed_line: format!("{current}  /root/hel\n"),
4937            commands: RefCell::new(Vec::new()),
4938        };
4939        let replaced = replace_remote_worker_binary_if_stale(
4940            &executor,
4941            &ssh_bare_locator("session-remote"),
4942            "session-remote",
4943            &CommandSpec::new("true", Vec::<String>::new()),
4944            &source,
4945        )
4946        .unwrap();
4947        assert!(!replaced, "a current remote binary must not be recopied");
4948        assert_eq!(
4949            executor.commands.borrow().len(),
4950            1,
4951            "only the digest probe runs when the binary is already current"
4952        );
4953    }
4954
4955    #[test]
4956    fn a_remote_recovery_plan_defers_binary_refresh_to_the_recovery_task() {
4957        let locator = ssh_bare_locator("session-remote");
4958        let refresh = worker_binary_refresh_plan(&locator, "session-remote")
4959            .unwrap()
4960            .expect("a remote target now gets a binary refresh");
4961        match refresh {
4962            WorkerBinaryRefresh::Remote(remote) => {
4963                assert_eq!(remote.session_id, "session-remote");
4964                assert_eq!(remote.locator, locator);
4965            }
4966            WorkerBinaryRefresh::Prepared(_) => {
4967                panic!("a remote target must defer, not prepare, its binary refresh")
4968            }
4969        }
4970    }
4971}