Skip to main content

mj_controller/hel_controller/
worker_binary.rs

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