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