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