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