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