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