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