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