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