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