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