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