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