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