1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use mj_core::config::{TargetTemplate, atomic_write, data_dir};
11use mj_core::state::{SessionState, State, TargetLocator};
12
13use crate::targets::{
14 self, CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProvisionStage,
15 ProvisionStageGuard,
16};
17
18use super::backend::{
19 ContainerOverrides, backend_bundle, backend_locator, backend_target,
20 configure_github_token_environment, controller_github_token, locator_after_provision,
21 preflight_target, use_github_https_urls,
22};
23use super::git_cache;
24use super::readiness::{connect_started_worker, wait_for_native_session_in_stage};
25use super::worker_binary::{bridge_readiness_stage, start_worker, worker_probe_diagnosis};
26use super::{Controller, execute_checked, now};
27
28const INHERITED_GIT_SETTINGS: &[&str] = &[
29 "diff.algorithm",
30 "fetch.prune",
31 "fetch.prunetags",
32 "init.defaultbranch",
33 "merge.conflictstyle",
34 "pull.ff",
35 "pull.rebase",
36 "push.autosetupremote",
37 "push.default",
38 "rebase.autostash",
39 "rerere.autoupdate",
40 "rerere.enabled",
41 "user.email",
42 "user.name",
43];
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(super) enum ProvisioningFailureDisposition {
47 Discard,
49 Preserve,
51}
52
53impl Controller {
54 pub async fn provision_session_controlled(
55 &mut self,
56 session_id: &str,
57 executor: &(impl CommandExecutor + Sync),
58 ) -> Result<()> {
59 self.provision_session_controlled_with_commit(session_id, executor, || Ok(()))
60 .await
61 }
62
63 pub async fn provision_session_controlled_with_commit(
64 &mut self,
65 session_id: &str,
66 executor: &(impl CommandExecutor + Sync),
67 grant_commit: impl FnOnce() -> Result<()>,
68 ) -> Result<()> {
69 let github_token = controller_github_token();
70 let repositories = self
71 .provision_session_target_with_failure_disposition(
72 session_id,
73 executor,
74 github_token.as_deref(),
75 ProvisioningFailureDisposition::Discard,
76 )
77 .await?;
78 let setup = execute_concurrent_lanes(
79 || execute_repository_setup(&repositories, executor),
80 || self.install_worker_payload(session_id, executor),
81 );
82 let result = match setup {
83 Ok(((), (backend, worker_root))) => {
84 self.connect_and_start_worker(session_id, executor, &backend, &worker_root, true)
85 .await
86 }
87 Err(error) => Err(error),
88 };
89 match result {
90 Ok(native_session_id) => {
91 if let Err(error) = grant_commit() {
92 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
93 }
94 self.mark_worker_connected(session_id, native_session_id)
95 }
96 Err(error) => Err(self.rollback_failed_new_session(session_id, error, executor)?),
97 }
98 }
99
100 pub async fn provision_subagent_session_controlled(
103 &mut self,
104 session_id: &str,
105 executor: &(impl CommandExecutor + Sync),
106 ) -> Result<()> {
107 let (backend, worker_root) = self.worker_placement(session_id)?;
108 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
109 let result = self.prepare_worker_files(session_id, &backend, &worker_root, syncing);
110 let result = match result {
111 Ok(()) => {
112 self.connect_and_start_worker(session_id, executor, &backend, &worker_root, false)
113 .await
114 }
115 Err(error) => Err(error),
116 };
117 match result {
118 Ok(native_session_id) => self.mark_worker_connected(session_id, native_session_id),
119 Err(error) => {
120 if let Err(stop_error) =
121 super::worker_binary::stop_worker(executor, &backend, &worker_root)
122 {
123 tracing::warn!(
124 session_id,
125 error = format!("{stop_error:#}"),
126 "failed sub-agent worker could not be stopped cleanly"
127 );
128 }
129 let record = self
130 .state
131 .sessions
132 .get_mut(session_id)
133 .context("failed sub-agent session disappeared")?;
134 record.state = SessionState::Error;
135 record.updated_at = super::now();
136 record.last_error = Some(format!("sub-agent startup failed: {error:#}"));
137 crate::database::save_lifecycle_session(record)?;
138 Err(error)
139 }
140 }
141 }
142
143 fn rollback_failed_new_session(
144 &mut self,
145 session_id: &str,
146 error: anyhow::Error,
147 executor: &impl CommandExecutor,
148 ) -> Result<anyhow::Error> {
149 let session = self
150 .state
151 .sessions
152 .get(session_id)
153 .with_context(|| format!("unknown session {session_id}"))?
154 .clone();
155 let target_cleanup = match session.target.as_ref() {
156 Some(locator) => (|| -> Result<()> {
157 let backend = backend_locator(locator, &session, &self.config)?;
158 targets::close_plan(&backend, session_id)?
159 .execute(&CancellableProcessExecutor::with_timeout(
162 Duration::from_secs(15),
163 ))
164 .map(|_| ())
165 })(),
166 None => Ok(()),
167 };
168 let worktree_cleanup =
169 self.cleanup_new_session_worktree_after_failure(session_id, executor);
170 let cleanup_error = [target_cleanup, worktree_cleanup]
171 .into_iter()
172 .filter_map(Result::err)
173 .map(|error| format!("{error:#}"))
174 .collect::<Vec<_>>()
175 .join("; ");
176 if !cleanup_error.is_empty() {
177 tracing::warn!(
178 session_id,
179 error = %cleanup_error,
180 "new-session rollback cleanup reported failures"
181 );
182 }
183 let original = format!("{error:#}");
184 let original = match persist_launch_failure(session_id, &original) {
185 Ok(path) => format!("{original}; full diagnostic saved to {}", path.display()),
186 Err(save_error) => {
187 format!("{original}; saving the local diagnostic failed: {save_error:#}")
188 }
189 };
190 let failure = apply_failed_new_session_rollback(
191 &mut self.state,
192 session_id,
193 &original,
194 (!cleanup_error.is_empty()).then_some(cleanup_error),
195 );
196 self.persist_session_state(session_id)?;
197 Ok(failure)
198 }
199
200 pub async fn provision_session_with(
201 &mut self,
202 session_id: &str,
203 executor: &(impl CommandExecutor + Sync),
204 ) -> Result<()> {
205 self.provision_session_with_github_token(session_id, executor, None)
206 .await
207 }
208
209 async fn provision_session_with_github_token(
210 &mut self,
211 session_id: &str,
212 executor: &(impl CommandExecutor + Sync),
213 github_token: Option<&str>,
214 ) -> Result<()> {
215 self.provision_session_with_failure_disposition(
216 session_id,
217 executor,
218 github_token,
219 ProvisioningFailureDisposition::Discard,
220 )
221 .await
222 }
223
224 pub(super) async fn provision_session_with_failure_disposition(
225 &mut self,
226 session_id: &str,
227 executor: &(impl CommandExecutor + Sync),
228 github_token: Option<&str>,
229 failure_disposition: ProvisioningFailureDisposition,
230 ) -> Result<()> {
231 let repositories = self
232 .provision_session_target_with_failure_disposition(
233 session_id,
234 executor,
235 github_token,
236 failure_disposition,
237 )
238 .await?;
239 match execute_repository_setup(&repositories, executor) {
240 Ok(()) => Ok(()),
241 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
242 Err(self.rollback_failed_new_session(session_id, error, executor)?)
243 }
244 Err(error) => Err(error),
245 }
246 }
247
248 async fn provision_session_target_with_failure_disposition(
249 &mut self,
250 session_id: &str,
251 executor: &(impl CommandExecutor + Sync),
252 github_token: Option<&str>,
253 failure_disposition: ProvisioningFailureDisposition,
254 ) -> Result<targets::CommandPlan> {
255 let session = self
256 .state
257 .sessions
258 .get(session_id)
259 .with_context(|| format!("unknown session {session_id}"))?
260 .clone();
261 if session.state != SessionState::Provisioning {
262 bail!("session {session_id} is not provisioning");
263 }
264 let preparation = (|| {
265 let template = self
266 .config
267 .targets
268 .get(&session.target_template_id)
269 .context("target template disappeared during provisioning")?;
270 let profile = self
271 .config
272 .profiles
273 .get(&session.last_profile)
274 .context("harness profile disappeared during provisioning")?;
275 super::worker_binary::preflight_harness(template, profile, executor)?;
276 self.prepare_managed_raw_worktree(session_id, executor)
277 })();
278 let created_worktree = match preparation {
279 Ok(created) => created,
280 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
281 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
282 }
283 Err(error) => return Err(error),
284 };
285 let session = self
286 .state
287 .sessions
288 .get(session_id)
289 .expect("session retained after managed worktree preparation")
290 .clone();
291 let result = (|| {
294 let template = self
295 .config
296 .targets
297 .get(&session.target_template_id)
298 .context("target template disappeared during provisioning")?;
299 if matches!(template, TargetTemplate::AwsEc2 { .. }) {
300 for resource in &session.additional_mounts {
301 ensure!(
302 resource.source.is_dir(),
303 "attached resource source is not a directory: {}",
304 resource.source.display()
305 );
306 }
307 }
308 let mut target = backend_target(
309 template,
310 session.resource_allocation.as_ref(),
311 ContainerOverrides::for_session(&session),
312 )?;
313 let mut runtime_mounts = if matches!(target, targets::TargetTemplate::AwsEc2(_)) {
314 Vec::new()
315 } else {
316 session.additional_mounts.clone()
317 };
318 for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
323 executor.notify_notice(¬ice);
324 }
325 let mut bundle = if session.project_directory.is_some() {
326 None
327 } else if failure_disposition == ProvisioningFailureDisposition::Preserve {
328 Some(super::network_git::checkpoint_bundle(&session)?)
329 } else {
330 Some(backend_bundle(
331 self.config
332 .bundles
333 .get(&session.bundle_id)
334 .context("session bundle is missing")?,
335 executor,
336 )?)
337 };
338 let container_github_token =
339 github_token.filter(|_| configure_github_token_environment(&mut target));
340 if container_github_token.is_some()
341 && let Some(bundle) = bundle.as_mut()
342 {
343 use_github_https_urls(bundle);
344 }
345 preflight_target(template, executor)?;
346 let prepared_cache = bundle.as_mut().and_then(|bundle| {
347 git_cache::prepare(
348 &target,
349 session_id,
350 bundle,
351 &mut runtime_mounts,
352 container_github_token,
353 executor,
354 )
355 });
356 let provision = if let Some(project_directory) = &session.project_directory {
357 targets::provision_bare_project_plan(
358 &target,
359 session_id,
360 &project_directory.to_string_lossy(),
361 )
362 } else {
363 bundle
364 .as_ref()
365 .context("project bundle disappeared during provisioning")
366 .and_then(|bundle| {
367 targets::provision_plan(&target, session_id, bundle, &runtime_mounts)
368 })
369 };
370 let mut provision = match provision {
371 Ok(provision) => provision,
372 Err(error) => {
373 if let Some(cache) = &prepared_cache {
374 let _ = cache.cleanup(executor);
375 }
376 return Err(error);
377 }
378 };
379 if let Some(token) = container_github_token
380 && let Err(error) =
381 provision.provide_target_environment_secret(&target, "GH_TOKEN", token)
382 {
383 if let Some(cache) = &prepared_cache {
384 let _ = cache.cleanup(executor);
385 }
386 return Err(error);
387 }
388
389 let started = Instant::now();
390 let result =
391 provision_target_creation(&provision, &target, session_id, executor, |outputs| {
392 locator_after_provision(
393 template,
394 &target,
395 session_id,
396 outputs.first(),
397 executor,
398 )
399 })
400 .map(|(locator, remainder)| (locator, remainder, bundle));
401 if result.is_err()
402 && let Some(cache) = &prepared_cache
403 {
404 if let Some(locator) = provisioned_locator(&target, session_id, None) {
405 let _ = targets::close_plan(&locator, session_id)
406 .and_then(|plan| plan.execute(executor).map(|_| ()));
407 } else {
408 let _ = cache.cleanup(executor);
409 }
410 }
411 tracing::debug!(
412 session_id,
413 elapsed_ms = started.elapsed().as_millis(),
414 "provisioning plan execution completed"
415 );
416 result
417 })();
418 let result = match result {
419 Err(error)
420 if created_worktree
421 && failure_disposition == ProvisioningFailureDisposition::Discard =>
422 {
423 return Err(self.fail_new_session_with_cleanup(session_id, error, executor)?);
424 }
425 Err(error) if failure_disposition == ProvisioningFailureDisposition::Preserve => {
426 Err(error)
427 }
428 Err(error) => {
429 let error = match apply_new_session_provisioning_result(
430 &mut self.state,
431 session_id,
432 Err(error),
433 ) {
434 Ok(()) => unreachable!("an unsuccessful provisioning result returned Ok"),
435 Err(error) => error,
436 };
437 return match self.persist_session_state(session_id) {
438 Ok(()) => Err(error),
439 Err(persistence_error) => Err(error.context(format!(
440 "persist removal of failed provisioning session {session_id}: {persistence_error:#}"
441 ))),
442 };
443 }
444 Ok((locator, remainder, bundle)) => {
445 apply_new_session_provisioning_result(&mut self.state, session_id, Ok(locator))?;
446 let session = &self.state.sessions[session_id];
447 let backend = backend_locator(
448 session
449 .target
450 .as_ref()
451 .context("provisioned target disappeared")?,
452 session,
453 &self.config,
454 )?;
455 if matches!(backend, targets::TargetLocator::AwsEc2 { .. }) {
456 targets::provision_on_locator_plan(
457 &backend,
458 session_id,
459 bundle
460 .as_ref()
461 .context("AWS provisioning requires a project bundle")?,
462 )
463 } else {
464 Ok(remainder)
465 }
466 }
467 };
468 let result = match result {
469 Err(error) if failure_disposition == ProvisioningFailureDisposition::Discard => {
470 return Err(self.rollback_failed_new_session(session_id, error, executor)?);
471 }
472 result => result,
473 };
474 if result.is_ok()
475 && let Some(session) = self.state.sessions.get(session_id)
476 && let Some(directory) = session
477 .managed_worktree
478 .as_ref()
479 .map(|worktree| worktree.source_project_directory.clone())
480 .or_else(|| session.project_directory.clone())
481 && let Some(template) = self.config.targets.get(&session.target_template_id)
482 {
483 let host = match template {
484 TargetTemplate::LocalBare => Some("local"),
485 TargetTemplate::SshBare { ssh, .. } => Some(ssh.host.as_str()),
486 _ => None,
487 };
488 if let Some(host) = host {
489 self.state.remember_project_directory(host, &directory);
490 crate::database::remember_project_directory(host, &directory)?;
491 }
492 }
493 self.persist_session_state(session_id)?;
494 result
495 }
496
497 pub fn mark_worker_connected(
498 &mut self,
499 session_id: &str,
500 native_session_id: Option<String>,
501 ) -> Result<()> {
502 let session = self
503 .state
504 .sessions
505 .get(session_id)
506 .with_context(|| format!("unknown session {session_id}"))?;
507 if session.target.is_none() {
508 bail!("session {session_id} has no provisioned target");
509 }
510 let updated_at = now();
511 crate::database::mark_session_worker_connected(
512 session_id,
513 native_session_id.as_deref(),
514 &updated_at,
515 )?;
516 let session = self
517 .state
518 .sessions
519 .get_mut(session_id)
520 .expect("session disappeared after its worker connection was saved");
521 session.state = SessionState::Running;
522 if native_session_id.is_some() {
523 session.native_session_id = native_session_id;
524 }
525 session.updated_at = updated_at;
526 session.last_error = None;
527 Ok(())
528 }
529
530 fn install_worker_payload(
531 &self,
532 session_id: &str,
533 executor: &impl CommandExecutor,
534 ) -> Result<(targets::TargetLocator, String)> {
535 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
537 let (backend, worker_root) = self.worker_placement(session_id)?;
538 self.prepare_worker_files(session_id, &backend, &worker_root, syncing)?;
539 install_attached_resources(&self.state, session_id, &backend, &worker_root, syncing)?;
540 Ok((backend, worker_root))
541 }
542
543 async fn connect_and_start_worker(
544 &self,
545 session_id: &str,
546 executor: &impl CommandExecutor,
547 backend: &targets::TargetLocator,
548 worker_root: &str,
549 initialize_workspace: bool,
550 ) -> Result<Option<String>> {
551 let syncing = &StagedExecutor::new(executor, ProvisionStage::Syncing);
552 if initialize_workspace {
553 install_inherited_git_settings(executor, backend, session_id)?;
554 self.initialize_network_workspaces(session_id, backend, syncing)?;
555 }
556 let session = self
557 .state
558 .sessions
559 .get(session_id)
560 .with_context(|| format!("unknown session {session_id}"))?;
561 let profile = self
562 .config
563 .profiles
564 .get(&session.last_profile)
565 .with_context(|| format!("unknown profile {}", session.last_profile))?;
566 let readiness_stage = bridge_readiness_stage(profile);
567 let reconnect = &targets::reconnect_plan(backend, session_id)?.commands[0];
568 let readiness = async {
569 let mut relay = {
570 let _starting = ProvisionStageGuard::new(executor, ProvisionStage::Starting);
571 start_worker(executor, backend, worker_root)?;
572 connect_started_worker(reconnect, session_id, executor, backend, worker_root)
573 .await?
574 };
575 let native_session_id =
576 wait_for_native_session_in_stage(&mut relay, executor, readiness_stage).await?;
577 Ok(Some(native_session_id))
578 }
579 .await;
580 match readiness {
581 Ok(native_session_id) => Ok(native_session_id),
582 Err(error) => Err(worker_probe_diagnosis(
583 executor,
584 backend,
585 worker_root,
586 error,
587 )),
588 }
589 }
590}
591
592const MAX_LAUNCH_DIAGNOSTIC_BYTES: usize = 64 * 1024;
593
594const RETAINED_LAUNCH_DIAGNOSTICS: usize = 20;
595
596fn persist_launch_failure(session_id: &str, detail: &str) -> Result<PathBuf> {
597 persist_launch_failure_to(&data_dir().join("diagnostics"), session_id, detail)
598}
599
600fn persist_launch_failure_to(directory: &Path, session_id: &str, detail: &str) -> Result<PathBuf> {
601 mj_core::config::validate_id("session", session_id)?;
602 std::fs::create_dir_all(directory).with_context(|| {
603 format!(
604 "create launch diagnostics directory {}",
605 directory.display()
606 )
607 })?;
608 #[cfg(unix)]
609 {
610 use std::os::unix::fs::PermissionsExt;
611 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
612 }
613 let path = directory.join(format!("{session_id}-launch-error.txt"));
614 let detail = bounded_launch_diagnostic(detail);
615 let body = format!(
616 "Hel session launch failure\nsession: {session_id}\nat: {}\n\n{detail}\n",
617 now()
618 );
619 atomic_write(&path, body.as_bytes())?;
620 prune_launch_diagnostics(directory)?;
621 Ok(path)
622}
623
624fn bounded_launch_diagnostic(detail: &str) -> String {
625 if detail.len() <= MAX_LAUNCH_DIAGNOSTIC_BYTES {
626 return detail.to_owned();
627 }
628 let mut head_end = MAX_LAUNCH_DIAGNOSTIC_BYTES / 4;
629 while !detail.is_char_boundary(head_end) {
630 head_end -= 1;
631 }
632 let tail_bytes = MAX_LAUNCH_DIAGNOSTIC_BYTES - head_end;
633 let mut tail_start = detail.len() - tail_bytes;
634 while !detail.is_char_boundary(tail_start) {
635 tail_start += 1;
636 }
637 format!(
638 "{}\n\n[... launch diagnostic truncated ...]\n\n{}",
639 &detail[..head_end],
640 &detail[tail_start..]
641 )
642}
643
644fn prune_launch_diagnostics(directory: &Path) -> Result<()> {
645 let mut diagnostics = Vec::new();
646 for entry in std::fs::read_dir(directory)? {
647 let entry = entry?;
648 if !entry
649 .file_name()
650 .to_str()
651 .is_some_and(|name| name.ends_with("-launch-error.txt"))
652 {
653 continue;
654 }
655 diagnostics.push((entry.metadata()?.modified()?, entry.path()));
656 }
657 diagnostics.sort_by_key(|entry| std::cmp::Reverse(entry.0));
658 for (_, path) in diagnostics.into_iter().skip(RETAINED_LAUNCH_DIAGNOSTICS) {
659 std::fs::remove_file(&path)
660 .with_context(|| format!("prune old launch diagnostic {}", path.display()))?;
661 }
662 Ok(())
663}
664
665fn apply_new_session_provisioning_result(
666 state: &mut State,
667 session_id: &str,
668 result: Result<TargetLocator>,
669) -> Result<()> {
670 match result {
671 Ok(locator) => {
672 let record = state.sessions.get_mut(session_id).unwrap();
673 record.target = Some(locator);
674 record.state = SessionState::Disconnected;
677 record.updated_at = now();
678 record.last_error = None;
679 Ok(())
680 }
681 Err(error) => {
682 let record = state.sessions.get_mut(session_id).unwrap();
683 record.state = SessionState::Error;
684 record.target = None;
685 record.updated_at = now();
686 record.last_error = Some(format!("session provisioning failed: {error:#}"));
687 Err(error)
688 }
689 }
690}
691
692pub(super) fn apply_failed_new_session_rollback(
693 state: &mut State,
694 session_id: &str,
695 original_error: &str,
696 cleanup_error: Option<String>,
697) -> anyhow::Error {
698 match cleanup_error {
699 None => {
700 let record = state.sessions.get_mut(session_id).unwrap();
701 record.state = SessionState::Error;
702 record.target = None;
703 record.updated_at = now();
704 record.last_error = Some(format!("worker bootstrap failed: {original_error}"));
705 anyhow::anyhow!("{original_error}; partial target removed and failed session retained")
706 }
707 Some(cleanup_error) => {
708 let failure = format!(
709 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
710 );
711 let record = state.sessions.get_mut(session_id).unwrap();
712 record.state = SessionState::Error;
713 record.updated_at = now();
714 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
715 anyhow::anyhow!(failure)
716 }
717 }
718}
719
720pub(super) fn install_attached_resources(
721 state: &State,
722 session_id: &str,
723 backend: &targets::TargetLocator,
724 worker_root: &str,
725 executor: &impl CommandExecutor,
726) -> Result<()> {
727 let targets::TargetLocator::AwsEc2 { .. } = backend else {
728 return Ok(());
729 };
730 let session = state
731 .sessions
732 .get(session_id)
733 .with_context(|| format!("unknown session {session_id}"))?;
734 if session.additional_mounts.is_empty() {
735 return Ok(());
736 }
737 for resource in &session.additional_mounts {
738 let install = targets::command_on_locator(
739 backend,
740 session_id,
741 vec![
742 format!("{worker_root}/hel"),
743 "worker".into(),
744 "install-resource".into(),
745 "--destination".into(),
746 resource.destination.to_string_lossy().into_owned(),
747 ],
748 "stream attached resource",
749 )?;
750 mj_checkpoint::resources::stream_resource(&resource.source, |stream| {
751 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
752 })
753 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
754 }
755 Ok(())
756}
757
758pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
762 first: impl FnOnce() -> Result<A> + Send,
763 second: impl FnOnce() -> Result<B> + Send,
764) -> Result<(A, B)> {
765 std::thread::scope(|scope| {
766 let second = scope.spawn(second);
767 let first = first();
768 let second = second.join().unwrap_or_else(|panic| {
769 Err(anyhow::anyhow!(
770 "concurrent target lane panicked: {}",
771 targets::command_thread_panic_message(panic.as_ref())
772 ))
773 });
774 match (first, second) {
775 (Err(error), _) => Err(error),
776 (Ok(_), Err(error)) => Err(error),
777 (Ok(first), Ok(second)) => Ok((first, second)),
778 }
779 })
780}
781
782fn execute_repository_setup(
783 plan: &targets::CommandPlan,
784 executor: &(impl CommandExecutor + Sync),
785) -> Result<()> {
786 if plan.commands.is_empty() {
787 return Ok(());
788 }
789 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
790 plan.execute_concurrent(executor).map(|_| ())
791}
792
793#[cfg(test)]
800fn provision_target(
801 plan: &targets::CommandPlan,
802 target: &targets::TargetTemplate,
803 session_id: &str,
804 executor: &(impl CommandExecutor + Sync),
805 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
806) -> Result<TargetLocator> {
807 let Some((creation, remainder)) = plan.split_at_target_creation() else {
808 return discover(&plan.execute_concurrent(executor)?);
810 };
811 let mut outputs = creation.execute_concurrent(executor)?;
812 let result = match remainder.execute_concurrent(executor) {
813 Ok(rest) => {
814 outputs.extend(rest);
815 discover(&outputs)
816 }
817 Err(error) => Err(error),
818 };
819 result.map_err(|error| {
820 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
821 Some(note) => error.context(note),
822 None => error,
823 }
824 })
825}
826
827fn provision_target_creation(
831 plan: &targets::CommandPlan,
832 target: &targets::TargetTemplate,
833 session_id: &str,
834 executor: &(impl CommandExecutor + Sync),
835 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
836) -> Result<(TargetLocator, targets::CommandPlan)> {
837 let Some((creation, remainder)) = plan.split_at_target_creation() else {
838 let outputs = plan.execute_concurrent(executor)?;
841 return discover(&outputs).map(|locator| {
842 (
843 locator,
844 targets::CommandPlan {
845 description: plan.description.clone(),
846 commands: Vec::new(),
847 },
848 )
849 });
850 };
851 let outputs = creation.execute_concurrent(executor)?;
852 discover(&outputs)
853 .map(|locator| (locator, remainder))
854 .map_err(|error| {
855 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
856 Some(note) => error.context(note),
857 None => error,
858 }
859 })
860}
861
862fn cleanup_failed_provision(
869 target: &targets::TargetTemplate,
870 session_id: &str,
871 create_output: Option<&CommandOutput>,
872 executor: &impl CommandExecutor,
873) -> Option<String> {
874 let locator = provisioned_locator(target, session_id, create_output)?;
875 let leak = format!(
876 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
877 );
878 let plan = match targets::close_plan(&locator, session_id) {
879 Ok(plan) => plan,
880 Err(error) => {
881 tracing::warn!(
882 session_id,
883 error = format!("{error:#}"),
884 "could not build provisioning cleanup plan"
885 );
886 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
887 }
888 };
889 let purpose = plan
890 .commands
891 .iter()
892 .map(|command| command.purpose.clone())
893 .collect::<Vec<_>>()
894 .join("; ");
895 let Err(error) = plan.execute(executor) else {
896 return Some(format!("cleanup succeeded: {purpose}"));
897 };
898 match targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
899 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
900 Ok(false) => {
901 tracing::warn!(
902 session_id,
903 error = format!("{error:#}"),
904 "provisioning cleanup failed and the target may still exist"
905 );
906 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
907 }
908 Err(confirm_error) => {
909 tracing::warn!(
910 session_id,
911 error = format!("{confirm_error:#}"),
912 "could not confirm whether the failed provisioning target was removed"
913 );
914 Some(format!(
915 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
916 ))
917 }
918 }
919}
920
921fn provisioned_locator(
926 target: &targets::TargetTemplate,
927 session_id: &str,
928 create_output: Option<&CommandOutput>,
929) -> Option<targets::TargetLocator> {
930 let container_id = || targets::resource_name(session_id).ok();
931 Some(match target {
932 targets::TargetTemplate::LocalBare => return None,
935 targets::TargetTemplate::LocalPodman(container) => targets::TargetLocator::LocalPodman {
936 container_id: container_id()?,
937 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
938 },
939 targets::TargetTemplate::LocalDocker(_) => targets::TargetLocator::LocalDocker {
940 container_id: container_id()?,
941 },
942 targets::TargetTemplate::AppleContainer(_) => targets::TargetLocator::AppleContainer {
943 container_id: container_id()?,
944 },
945 targets::TargetTemplate::SshPodman { ssh, container } => {
946 targets::TargetLocator::SshPodman {
947 ssh: ssh.clone(),
948 container_id: container_id()?,
949 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
950 }
951 }
952 targets::TargetTemplate::SshDocker { ssh, .. } => targets::TargetLocator::SshDocker {
953 ssh: ssh.clone(),
954 container_id: container_id()?,
955 },
956 targets::TargetTemplate::SshBare { ssh, .. } => targets::TargetLocator::SshBare {
957 ssh: ssh.clone(),
958 workspace: targets::workspace_for(target, session_id).ok()?,
959 worker_id: None,
960 },
961 targets::TargetTemplate::AwsEc2(aws) => targets::TargetLocator::AwsEc2 {
962 profile: aws.profile.clone(),
963 region: aws.region.clone(),
964 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
965 .ok()?
966 .pointer("/Instances/0/InstanceId")?
967 .as_str()?
968 .to_owned(),
969 ssh: aws.ssh.clone(),
970 workspace: targets::workspace_for(target, session_id).ok()?,
971 },
972 })
973}
974
975pub(super) fn enforce_overlay_capable_mounts(
986 target: &targets::TargetTemplate,
987 mounts: &mut [targets::AdditionalMount],
988 executor: &impl CommandExecutor,
989) -> Vec<String> {
990 let ssh = match target {
991 targets::TargetTemplate::LocalPodman(_) | targets::TargetTemplate::LocalDocker(_) => None,
992 targets::TargetTemplate::SshPodman { ssh, .. }
993 | targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
994 _ => return Vec::new(),
995 };
996 let overlaid = mounts
997 .iter()
998 .filter(|mount| !mount.read_only)
999 .map(|mount| mount.source.clone())
1000 .collect::<Vec<_>>();
1001 if overlaid.is_empty() {
1002 return Vec::new();
1003 }
1004 let filesystems = match targets::probe_filesystem_types(ssh, &overlaid, executor) {
1005 Ok(filesystems) => filesystems,
1006 Err(error) => {
1007 tracing::warn!(
1008 error = format!("{error:#}"),
1009 "could not probe attached-directory filesystems; preserving overlay mounts"
1010 );
1011 return vec![format!(
1012 "Could not read the filesystem under the attached directories, so they keep the \
1013 copy-on-write overlay: {error:#}"
1014 )];
1015 }
1016 };
1017 let mut notices = Vec::new();
1018 for (mount, filesystem) in mounts
1019 .iter_mut()
1020 .filter(|mount| !mount.read_only)
1021 .zip(filesystems)
1022 {
1023 let Some(reason) = targets::overlay_unsupported_filesystem(&filesystem) else {
1024 continue;
1025 };
1026 mount.read_only = true;
1027 notices.push(format!(
1028 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1029 mount.source.display()
1030 ));
1031 }
1032 notices
1033}
1034
1035pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1039 inner: &'a E,
1040 stage: ProvisionStage,
1041 _guard: ProvisionStageGuard<'a, E>,
1042}
1043
1044impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1045 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1046 Self {
1047 inner,
1048 stage,
1049 _guard: ProvisionStageGuard::new(inner, stage),
1050 }
1051 }
1052
1053 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1054 if command.stage.is_some() {
1055 return command.clone();
1056 }
1057 command.clone().stage(self.stage)
1058 }
1059}
1060
1061impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1062 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1063 self.inner.execute(&self.staged(command))
1064 }
1065
1066 fn cancellation_requested(&self) -> bool {
1067 self.inner.cancellation_requested()
1068 }
1069
1070 fn stage_started(&self, stage: ProvisionStage) {
1071 self.inner.stage_started(stage);
1072 }
1073
1074 fn stage_finished(&self, stage: ProvisionStage) {
1075 self.inner.stage_finished(stage);
1076 }
1077
1078 fn notify_notice(&self, notice: &str) {
1079 self.inner.notify_notice(notice);
1080 }
1081
1082 fn execute_with_stdin(
1083 &self,
1084 command: &CommandSpec,
1085 input: &mut (dyn std::io::Read + Send),
1086 ) -> Result<CommandOutput> {
1087 self.inner.execute_with_stdin(&self.staged(command), input)
1088 }
1089}
1090
1091fn execute_checked_with_stdin(
1092 executor: &impl CommandExecutor,
1093 command: &CommandSpec,
1094 input: &mut (dyn std::io::Read + Send),
1095) -> Result<CommandOutput> {
1096 let output = executor.execute_with_stdin(command, input)?;
1097 if output.status != 0 {
1098 bail!(
1099 "{} failed with status {}: {}",
1100 command.purpose,
1101 output.status,
1102 String::from_utf8_lossy(&output.stderr)
1103 );
1104 }
1105 Ok(output)
1106}
1107
1108pub(super) fn install_inherited_git_settings(
1109 executor: &impl CommandExecutor,
1110 locator: &targets::TargetLocator,
1111 session_id: &str,
1112) -> Result<()> {
1113 let settings = if inherits_controller_git_settings(locator) {
1114 controller_git_settings()?
1115 } else {
1116 BTreeMap::new()
1117 };
1118 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1119 execute_checked(executor, command)?;
1120 }
1121 Ok(())
1122}
1123
1124fn inherits_controller_git_settings(locator: &targets::TargetLocator) -> bool {
1125 !matches!(
1126 locator,
1127 targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. }
1128 )
1129}
1130
1131fn inherited_git_setting_commands(
1132 locator: &targets::TargetLocator,
1133 session_id: &str,
1134 settings: BTreeMap<String, String>,
1135) -> Result<Vec<CommandSpec>> {
1136 if matches!(locator, targets::TargetLocator::SshBare { .. }) {
1137 return Ok(Vec::new());
1138 }
1139 settings
1140 .into_iter()
1141 .map(|(key, value)| {
1142 targets::command_on_locator(
1143 locator,
1144 session_id,
1145 vec![
1146 "git".into(),
1147 "config".into(),
1148 "--global".into(),
1149 "--replace-all".into(),
1150 "--".into(),
1151 key.clone(),
1152 value,
1153 ],
1154 format!("inherit Git setting {key}"),
1155 )
1156 })
1157 .collect()
1158}
1159
1160fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1161 let output = match Command::new("git")
1162 .args(["config", "--global", "--includes", "--null", "--list"])
1163 .stdin(Stdio::null())
1164 .output()
1165 {
1166 Ok(output) => output,
1167 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1168 Err(error) => return Err(error).context("read controller Git configuration"),
1169 };
1170 if !output.status.success() {
1171 bail!(
1172 "read controller Git configuration failed with status {}: {}",
1173 output.status,
1174 String::from_utf8_lossy(&output.stderr).trim()
1175 );
1176 }
1177 parse_inherited_git_settings(&output.stdout)
1178}
1179
1180fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1181 let mut settings = BTreeMap::new();
1182 for entry in output
1183 .split(|byte| *byte == 0)
1184 .filter(|entry| !entry.is_empty())
1185 {
1186 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1187 let (key, value) = entry
1188 .split_once('\n')
1189 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1190 let key = key.to_ascii_lowercase();
1191 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1192 settings.insert(key, value.to_owned());
1193 }
1194 }
1195 Ok(settings)
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use std::collections::BTreeMap;
1201
1202 use std::sync::Mutex;
1203
1204 use mj_core::config::{
1205 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1206 ProjectRepository, SshConnection,
1207 };
1208 use mj_core::state::{SessionRecord, SessionState, State, TargetLocator};
1209
1210 use crate::targets::{self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget};
1211
1212 use crate::controller::SessionLaunchOptions;
1213
1214 use super::*;
1215
1216 struct ProbeExecutor {
1219 answer: std::result::Result<&'static str, &'static str>,
1220 notices: Mutex<Vec<String>>,
1221 }
1222
1223 impl ProbeExecutor {
1224 fn answering(answer: &'static str) -> Self {
1225 Self {
1226 answer: Ok(answer),
1227 notices: Mutex::new(Vec::new()),
1228 }
1229 }
1230
1231 fn failing(stderr: &'static str) -> Self {
1232 Self {
1233 answer: Err(stderr),
1234 notices: Mutex::new(Vec::new()),
1235 }
1236 }
1237 }
1238
1239 impl CommandExecutor for ProbeExecutor {
1240 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1241 assert_eq!(command.program, "stat", "only the probe may run here");
1242 Ok(match self.answer {
1243 Ok(filesystem) => CommandOutput {
1244 status: 0,
1245 stdout: format!("{filesystem}\n").into_bytes(),
1246 stderr: Vec::new(),
1247 },
1248 Err(stderr) => CommandOutput {
1249 status: 1,
1250 stdout: Vec::new(),
1251 stderr: stderr.as_bytes().to_vec(),
1252 },
1253 })
1254 }
1255
1256 fn notify_notice(&self, notice: &str) {
1257 self.notices.lock().unwrap().push(notice.to_owned());
1258 }
1259 }
1260
1261 fn podman_target() -> targets::TargetTemplate {
1262 targets::TargetTemplate::LocalPodman(ContainerTemplate {
1263 image: "ubuntu:24.04".into(),
1264 pull_policy: Default::default(),
1265 extra_run_args: Vec::new(),
1266 workspace_storage: Default::default(),
1267 })
1268 }
1269
1270 fn probe_bundle() -> ProjectBundleSpec {
1271 ProjectBundleSpec {
1272 primary: "app".into(),
1273 repositories: vec![crate::targets::RepositorySpec {
1274 url: Some("https://github.com/example/app.git".into()),
1275 push_urls: Vec::new(),
1276 destination: "app".into(),
1277 git_ref: None,
1278 reference: None,
1279 }],
1280 }
1281 }
1282
1283 fn ssh_docker_registration_config() -> Config {
1284 let mut config = Config::default();
1285 config.profiles.insert(
1286 "codex".into(),
1287 HarnessProfile {
1288 enabled: true,
1289 kind: HarnessKind::Codex,
1290 home: PathBuf::from("/home/dev/.codex"),
1291 environment: BTreeMap::new(),
1292 context_window_bytes: None,
1293 },
1294 );
1295 config.bundles.insert(
1296 "project".into(),
1297 ProjectBundle {
1298 primary_repo: "project".into(),
1299 repositories: vec![ProjectRepository {
1300 id: "project".into(),
1301 github: Some("owner/project".into()),
1302 local: None,
1303 destination: PathBuf::from("project"),
1304 git_ref: None,
1305 }],
1306 },
1307 );
1308 config.targets.insert(
1309 "docker".into(),
1310 TargetTemplate::SshDocker {
1311 ssh: SshConnection {
1312 host: "builder".into(),
1313 user: Some("agent".into()),
1314 identity_file: None,
1315 extra_args: Vec::new(),
1316 },
1317 container: ConfigContainer {
1318 image: "failimage:never".into(),
1319 pull_policy: Default::default(),
1320 platform: None,
1321 cpus: None,
1322 memory: None,
1323 environment: BTreeMap::new(),
1324 workspace_storage: Default::default(),
1325 },
1326 },
1327 );
1328 config
1329 }
1330
1331 #[test]
1332 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1333 let executor = ProbeExecutor::answering("nfs");
1334 let mut mounts = vec![AdditionalMount {
1335 source: PathBuf::from("/nfs/share"),
1336 destination: PathBuf::from("/mnt/share"),
1337 read_only: false,
1338 }];
1339
1340 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1341
1342 assert!(mounts[0].read_only);
1343 assert_eq!(notices.len(), 1);
1344 assert!(
1345 notices[0]
1346 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1347 "{notices:?}"
1348 );
1349 let plan = targets::provision_plan(
1350 &podman_target(),
1351 "0123456789abcdef0123456789abcdef",
1352 &probe_bundle(),
1353 &mounts,
1354 )
1355 .unwrap();
1356 assert!(
1357 plan.commands[0]
1358 .args
1359 .windows(2)
1360 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1361 "{:?}",
1362 plan.commands[0].args
1363 );
1364 }
1365
1366 #[test]
1367 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1368 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1369 let mut mounts = vec![AdditionalMount {
1370 source: PathBuf::from("/host/cache"),
1371 destination: PathBuf::from("/mnt/cache"),
1372 read_only: false,
1373 }];
1374
1375 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1376
1377 assert!(!mounts[0].read_only);
1378 assert_eq!(notices.len(), 1);
1379 assert!(
1380 notices[0].contains("keep the copy-on-write overlay")
1381 && notices[0].contains("cannot read file system information"),
1382 "{notices:?}"
1383 );
1384 let plan = targets::provision_plan(
1385 &podman_target(),
1386 "0123456789abcdef0123456789abcdef",
1387 &probe_bundle(),
1388 &mounts,
1389 )
1390 .unwrap();
1391 assert!(
1392 plan.commands[0]
1393 .args
1394 .windows(2)
1395 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1396 "{:?}",
1397 plan.commands[0].args
1398 );
1399 }
1400
1401 #[test]
1402 fn engines_without_an_overlay_to_lose_are_never_probed() {
1403 struct UnusedExecutor;
1404
1405 impl CommandExecutor for UnusedExecutor {
1406 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1407 panic!("this target must not probe: {}", command.program)
1408 }
1409 }
1410
1411 let mut mounts = vec![AdditionalMount {
1412 source: PathBuf::from("/host/cache"),
1413 destination: PathBuf::from("/mnt/cache"),
1414 read_only: false,
1415 }];
1416 for target in [
1417 targets::TargetTemplate::AppleContainer(ContainerTemplate {
1418 image: "ubuntu:24.04".into(),
1419 pull_policy: Default::default(),
1420 extra_run_args: Vec::new(),
1421 workspace_storage: Default::default(),
1422 }),
1423 targets::TargetTemplate::AwsEc2(targets::AwsTemplate {
1424 profile: "default".into(),
1425 region: "us-east-1".into(),
1426 launch_template: "lt-0123456789abcdef0".into(),
1427 launch_template_version: None,
1428 instance_type: None,
1429 ssh: SshTarget {
1430 destination: "ubuntu@example.test".into(),
1431 ssh_args: Vec::new(),
1432 },
1433 }),
1434 ] {
1435 assert!(
1436 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1437 );
1438 assert!(!mounts[0].read_only);
1439 }
1440 }
1441
1442 #[test]
1445 fn mounts_already_read_only_are_not_probed() {
1446 struct UnusedExecutor;
1447
1448 impl CommandExecutor for UnusedExecutor {
1449 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1450 panic!("a read-only mount must not probe: {}", command.program)
1451 }
1452 }
1453
1454 let mut mounts = vec![AdditionalMount {
1455 source: PathBuf::from("/host/cache"),
1456 destination: PathBuf::from("/mnt/cache"),
1457 read_only: true,
1458 }];
1459
1460 assert!(
1461 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1462 .is_empty()
1463 );
1464 }
1465
1466 #[test]
1467 fn failed_new_session_provisioning_retains_error_record() {
1468 let session_id = "0123456789abcdef0123456789abcdef";
1469 let record = SessionRecord {
1470 mjolnir_subagents: None,
1471 create_managed_worktree: None,
1472 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1473 archived: false,
1474 container_cpus: None,
1475 container_memory: None,
1476 id: session_id.into(),
1477 title: "new session".into(),
1478 harness_kind: mj_core::config::HarnessKind::Codex,
1479 last_profile: "codex".into(),
1480 bundle_id: "project".into(),
1481 project_directory: None,
1482 managed_worktree: None,
1483 target_template_id: "podman".into(),
1484 resource_allocation: None,
1485 additional_mounts: Vec::new(),
1486 state: SessionState::Provisioning,
1487 target: None,
1488 native_session_id: None,
1489 acp_session_title: None,
1490 session_title_override: None,
1491 created_at: "2026-08-12T00:00:00Z".into(),
1492 updated_at: "2026-08-12T00:00:00Z".into(),
1493 viewed_through_event_ordinal: 0,
1494 draft_input: String::new(),
1495 last_error: None,
1496 last_checkpoint_error: None,
1497 checkpoint: None,
1498 };
1499 let mut state = State::default();
1500 state.sessions.insert(session_id.into(), record);
1501
1502 let result = apply_new_session_provisioning_result(
1503 &mut state,
1504 session_id,
1505 Err(anyhow::anyhow!("container creation failed")),
1506 );
1507
1508 assert!(result.is_err());
1509 let retained = &state.sessions[session_id];
1510 assert_eq!(retained.state, SessionState::Error);
1511 assert!(retained.target.is_none());
1512 assert!(
1513 retained
1514 .last_error
1515 .as_deref()
1516 .unwrap()
1517 .contains("container creation failed")
1518 );
1519 }
1520
1521 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
1522
1523 #[test]
1524 fn failed_ssh_docker_preflight_retains_durable_error_record() {
1525 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1526 let directory = tempfile::tempdir().unwrap();
1527 let test = "failed_ssh_docker_preflight_retains_durable_error_record";
1528 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1529 command
1530 .args([
1531 "--exact",
1532 &format!("controller::provisioning::tests::{test}"),
1533 "--nocapture",
1534 ])
1535 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1536 .env("MJ_DATA_DIR", directory.path())
1537 .env("MJ_CONFIG_DIR", directory.path());
1538 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1539 assert!(
1540 output.status.success(),
1541 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1542 String::from_utf8_lossy(&output.stdout),
1543 String::from_utf8_lossy(&output.stderr)
1544 );
1545 return;
1546 }
1547
1548 let _writer = crate::database::install_isolated_test_writer();
1549 let config = ssh_docker_registration_config();
1550 config.save().unwrap();
1551 let mut controller = Controller {
1552 config,
1553 state: State::default(),
1554 };
1555 let session_id = controller
1556 .register_session_with_resources(
1557 "codex",
1558 "project",
1559 "docker",
1560 "failed image",
1561 SessionLaunchOptions {
1562 mjolnir_subagents: None,
1563 create_managed_worktree: None,
1564 initial_prompt: None,
1565 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1566 additional_mounts: Vec::new(),
1567 allow_dirty_local: false,
1568 resource_allocation: None,
1569 project_directory: None,
1570 session_title_override: None,
1571 },
1572 )
1573 .unwrap();
1574 assert!(
1575 crate::database::load_state()
1576 .unwrap()
1577 .sessions
1578 .contains_key(&session_id)
1579 );
1580
1581 let executor = RecordingExecutor::failing("check Docker daemon");
1582 let error =
1583 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1584 &session_id,
1585 &executor,
1586 None,
1587 ProvisioningFailureDisposition::Discard,
1588 ))
1589 .unwrap_err();
1590 let reported = format!("{error:#}");
1591 assert!(
1592 reported.contains("remote Docker preflight failed"),
1593 "{reported}"
1594 );
1595 assert!(
1596 executor.commands().iter().any(|argv| {
1597 let command = argv.join(" ");
1598 command.contains("'docker' 'version'")
1599 }),
1600 "the fake preflight did not run: {:?}",
1601 executor.commands()
1602 );
1603 let retained = &controller.state.sessions[&session_id];
1604 assert_eq!(retained.state, SessionState::Error);
1605 assert!(retained.target.is_none());
1606
1607 let reloaded = Controller::load().unwrap();
1608 let retained = &reloaded.state.sessions[&session_id];
1609 assert_eq!(retained.state, SessionState::Error);
1610 assert!(retained.target.is_none());
1611 assert!(retained.last_error.is_some());
1612 }
1613
1614 #[test]
1615 fn failed_node_preflight_retains_error_before_provisioning() {
1616 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1617 let directory = tempfile::tempdir().unwrap();
1618 let test = "failed_node_preflight_retains_error_before_provisioning";
1619 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1620 command
1621 .args([
1622 "--exact",
1623 &format!("controller::provisioning::tests::{test}"),
1624 "--nocapture",
1625 ])
1626 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1627 .env("MJ_DATA_DIR", directory.path())
1628 .env("MJ_CONFIG_DIR", directory.path());
1629 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1630 assert!(
1631 output.status.success(),
1632 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1633 String::from_utf8_lossy(&output.stdout),
1634 String::from_utf8_lossy(&output.stderr)
1635 );
1636 return;
1637 }
1638
1639 let _writer = crate::database::install_isolated_test_writer();
1640 let mut config = ssh_docker_registration_config();
1641 config.targets.insert(
1642 "docker".into(),
1643 TargetTemplate::SshBare {
1644 ssh: SshConnection {
1645 host: "builder".into(),
1646 user: Some("agent".into()),
1647 identity_file: None,
1648 extra_args: Vec::new(),
1649 },
1650 permissions: mj_core::config::PermissionMode::Guardian,
1651 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
1652 },
1653 );
1654 config.save().unwrap();
1655 let mut controller = Controller {
1656 config,
1657 state: State::default(),
1658 };
1659 let session_id = controller
1660 .register_session_with_resources(
1661 "codex",
1662 "project",
1663 "docker",
1664 "missing Node",
1665 SessionLaunchOptions {
1666 mjolnir_subagents: None,
1667 create_managed_worktree: None,
1668 initial_prompt: None,
1669 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1670 additional_mounts: Vec::new(),
1671 allow_dirty_local: false,
1672 resource_allocation: None,
1673 project_directory: Some("/srv/project".into()),
1674 session_title_override: None,
1675 },
1676 )
1677 .unwrap();
1678 assert!(
1679 crate::database::load_state()
1680 .unwrap()
1681 .sessions
1682 .contains_key(&session_id)
1683 );
1684
1685 let executor = RecordingExecutor::failing("preflight managed harness Node.js and npm");
1686 let error =
1687 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1688 &session_id,
1689 &executor,
1690 None,
1691 ProvisioningFailureDisposition::Discard,
1692 ))
1693 .unwrap_err();
1694 let reported = format!("{error:#}");
1695 assert!(reported.contains("Node.js 22+ and npm"), "{reported}");
1696 assert_eq!(
1697 executor.commands().len(),
1698 1,
1699 "preflight must fail before provisioning"
1700 );
1701 let retained = &controller.state.sessions[&session_id];
1702 assert_eq!(retained.state, SessionState::Error);
1703 assert!(retained.target.is_none());
1704
1705 let reloaded = Controller::load().unwrap();
1706 let retained = &reloaded.state.sessions[&session_id];
1707 assert_eq!(retained.state, SessionState::Error);
1708 assert!(retained.target.is_none());
1709 assert!(retained.last_error.is_some());
1710 }
1711
1712 #[test]
1713 fn failed_new_worker_start_retains_session_only_after_target_cleanup() {
1714 let session_id = "0123456789abcdef0123456789abcdef";
1715 let mut session = SessionRecord {
1716 mjolnir_subagents: None,
1717 create_managed_worktree: None,
1718 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1719 archived: false,
1720 container_cpus: None,
1721 container_memory: None,
1722 id: session_id.into(),
1723 title: "new session".into(),
1724 harness_kind: mj_core::config::HarnessKind::Kimi,
1725 last_profile: "kimi".into(),
1726 bundle_id: "raw-project".into(),
1727 project_directory: Some("/srv/project".into()),
1728 managed_worktree: None,
1729 target_template_id: "remote".into(),
1730 resource_allocation: None,
1731 additional_mounts: Vec::new(),
1732 state: SessionState::Disconnected,
1733 target: Some(TargetLocator::SshBare {
1734 host: "builder".into(),
1735 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
1736 worker_id: None,
1737 }),
1738 native_session_id: None,
1739 acp_session_title: None,
1740 session_title_override: None,
1741 created_at: "2026-08-12T00:00:00Z".into(),
1742 updated_at: "2026-08-12T00:00:00Z".into(),
1743 viewed_through_event_ordinal: 0,
1744 draft_input: String::new(),
1745 last_error: None,
1746 last_checkpoint_error: None,
1747 checkpoint: None,
1748 };
1749 let mut cleaned = State::default();
1750 cleaned.sessions.insert(session_id.into(), session.clone());
1751
1752 let failure =
1753 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
1754
1755 let retained = &cleaned.sessions[session_id];
1756 assert_eq!(retained.state, SessionState::Error);
1757 assert!(retained.target.is_none());
1758 assert!(failure.to_string().contains("failed session retained"));
1759
1760 session.state = SessionState::Disconnected;
1761 let mut cleanup_failed = State::default();
1762 cleanup_failed.sessions.insert(session_id.into(), session);
1763 let failure = apply_failed_new_session_rollback(
1764 &mut cleanup_failed,
1765 session_id,
1766 "ACP startup failed",
1767 Some("ssh unavailable".into()),
1768 );
1769 let retained = cleanup_failed.sessions.get(session_id).unwrap();
1770 assert_eq!(retained.state, SessionState::Error);
1771 assert!(retained.target.is_some());
1772 assert!(failure.to_string().contains("cleanup"));
1773 }
1774 #[test]
1775 fn launch_failure_is_persisted_separately_from_session_state() {
1776 let directory = tempfile::tempdir().unwrap();
1777 let session_id = "0123456789abcdef0123456789abcdef";
1778 let detail = format!(
1779 "specific startup cause\n{}\nstderr tail survives",
1780 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
1781 );
1782
1783 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
1784 let saved = std::fs::read_to_string(path).unwrap();
1785
1786 assert!(saved.contains("specific startup cause"));
1787 assert!(saved.contains("launch diagnostic truncated"));
1788 assert!(saved.contains("stderr tail survives"));
1789 #[cfg(unix)]
1790 {
1791 use std::os::unix::fs::PermissionsExt;
1792 assert_eq!(
1793 std::fs::metadata(directory.path())
1794 .unwrap()
1795 .permissions()
1796 .mode()
1797 & 0o777,
1798 0o700
1799 );
1800 }
1801 }
1802 #[test]
1803 fn inherited_git_settings_allow_only_portable_non_executable_values() {
1804 let settings = parse_inherited_git_settings(
1805 b"user.name\nAgent User\0USER.EMAIL\nagent@example.test\0pull.rebase\ntrue\0alias.deploy\n!ship\0credential.helper\nstore\0core.editor\nvim\0include.path\n/host/config\0user.name\nFinal User\0",
1806 )
1807 .unwrap();
1808
1809 assert_eq!(
1810 settings,
1811 BTreeMap::from([
1812 ("pull.rebase".into(), "true".into()),
1813 ("user.email".into(), "agent@example.test".into()),
1814 ("user.name".into(), "Final User".into()),
1815 ])
1816 );
1817 }
1818 #[test]
1819 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
1820 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
1821 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
1822 }
1823 #[test]
1824 fn inherited_git_settings_target_only_isolated_workers() {
1825 let ssh = SshTarget {
1826 destination: "worker@example.test".into(),
1827 ssh_args: vec!["-p".into(), "2222".into()],
1828 };
1829 let ephemeral = [
1830 targets::TargetLocator::LocalPodman {
1831 container_id: "abcdef012345".into(),
1832 workspace_storage: Default::default(),
1833 },
1834 targets::TargetLocator::AppleContainer {
1835 container_id: "abcdef012346".into(),
1836 },
1837 targets::TargetLocator::AwsEc2 {
1838 profile: "default".into(),
1839 region: "us-east-1".into(),
1840 instance_id: "i-1234567890abcdef0".into(),
1841 ssh: ssh.clone(),
1842 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
1843 .into(),
1844 },
1845 targets::TargetLocator::SshPodman {
1846 ssh: ssh.clone(),
1847 container_id: "abcdef012347".into(),
1848 workspace_storage: Default::default(),
1849 },
1850 ];
1851 for locator in &ephemeral {
1852 assert!(inherits_controller_git_settings(locator));
1853 let commands = inherited_git_setting_commands(
1854 locator,
1855 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1856 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
1857 )
1858 .unwrap();
1859 assert_eq!(commands.len(), 1);
1860 assert!(
1861 commands[0]
1862 .args
1863 .iter()
1864 .any(|argument| argument.contains("user.name"))
1865 );
1866 assert!(
1867 commands[0]
1868 .args
1869 .iter()
1870 .any(|argument| argument.contains("- Agent O'"))
1871 );
1872 }
1873
1874 let persistent = targets::TargetLocator::SshBare {
1875 worker_id: None,
1876 ssh,
1877 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1878 };
1879 let local = targets::TargetLocator::LocalBare {
1880 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1881 };
1882 assert!(!inherits_controller_git_settings(&persistent));
1883 assert!(!inherits_controller_git_settings(&local));
1884 assert!(
1885 inherited_git_setting_commands(
1886 &persistent,
1887 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1888 BTreeMap::from([("user.name".into(), "Agent".into())]),
1889 )
1890 .unwrap()
1891 .is_empty()
1892 );
1893 }
1894
1895 #[test]
1896 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
1897 let ssh = mj_core::config::SshConnection {
1898 host: "builder".into(),
1899 user: None,
1900 identity_file: None,
1901 extra_args: Vec::new(),
1902 };
1903 let guardian = TargetTemplate::SshBare {
1904 ssh: ssh.clone(),
1905 permissions: mj_core::config::PermissionMode::Guardian,
1906 workspace_prefix: ".local/share/hel/workspaces".into(),
1907 };
1908 let podman = TargetTemplate::SshPodman {
1909 ssh: ssh.clone(),
1910 container: mj_core::config::ContainerTemplate {
1911 image: "example.invalid/agent:latest".into(),
1912 pull_policy: Default::default(),
1913 platform: None,
1914 cpus: None,
1915 memory: None,
1916 environment: BTreeMap::new(),
1917 workspace_storage: Default::default(),
1918 },
1919 };
1920 let yolo = TargetTemplate::SshBare {
1921 ssh,
1922 permissions: mj_core::config::PermissionMode::Yolo,
1923 workspace_prefix: ".local/share/hel/workspaces".into(),
1924 };
1925
1926 assert_eq!(
1927 TargetTemplate::LocalBare.execution_policy(),
1928 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1929 );
1930 assert_eq!(
1931 guardian.execution_policy(),
1932 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1933 );
1934 assert_eq!(
1935 podman.execution_policy(),
1936 mj_core::config::ExecutionPolicy::Unconstrained
1937 );
1938 assert_eq!(
1939 yolo.execution_policy(),
1940 mj_core::config::ExecutionPolicy::Unconstrained
1941 );
1942 }
1943 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
1944
1945 struct RecordingExecutor {
1948 failing_purpose: String,
1949 commands: Mutex<Vec<Vec<String>>>,
1950 }
1951
1952 impl RecordingExecutor {
1953 fn failing(purpose: impl Into<String>) -> Self {
1954 Self {
1955 failing_purpose: purpose.into(),
1956 commands: Mutex::new(Vec::new()),
1957 }
1958 }
1959
1960 fn succeeding() -> Self {
1961 Self::failing(String::new())
1962 }
1963
1964 fn commands(&self) -> Vec<Vec<String>> {
1965 self.commands.lock().unwrap().clone()
1966 }
1967 }
1968
1969 impl CommandExecutor for RecordingExecutor {
1970 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1971 let mut argv = vec![command.program.clone()];
1972 argv.extend(command.args.clone());
1973 self.commands.lock().unwrap().push(argv);
1974 Ok(CommandOutput {
1975 status: i32::from(command.purpose == self.failing_purpose),
1976 stdout: Vec::new(),
1977 stderr: b"the step failed".to_vec(),
1978 })
1979 }
1980 }
1981
1982 fn container_targets() -> Vec<targets::TargetTemplate> {
1983 let container = ContainerTemplate {
1984 image: "ubuntu:24.04".into(),
1985 pull_policy: Default::default(),
1986 extra_run_args: Vec::new(),
1987 workspace_storage: Default::default(),
1988 };
1989 vec![
1990 targets::TargetTemplate::LocalPodman(container.clone()),
1991 targets::TargetTemplate::AppleContainer(container.clone()),
1992 targets::TargetTemplate::SshPodman {
1993 ssh: SshTarget {
1994 destination: "dev@example.test".into(),
1995 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
1996 },
1997 container,
1998 },
1999 ]
2000 }
2001
2002 #[test]
2003 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
2004 let name = targets::resource_name(PROVISIONED_SESSION).unwrap();
2005 for target in container_targets() {
2006 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2007 .unwrap();
2008 let executor = RecordingExecutor::failing("clone app");
2009
2010 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2011 unreachable!("locator discovery must not run after a failed plan")
2012 })
2013 .unwrap_err();
2014
2015 let reported = format!("{error:#}");
2016 assert!(reported.contains("clone app failed"), "{reported}");
2017 assert!(reported.contains("cleanup succeeded"), "{reported}");
2018 let removal = executor
2020 .commands()
2021 .into_iter()
2022 .map(|arguments| arguments.join(" ").replace('\'', ""))
2023 .find(|command| command.contains("rm --force") && command.contains(&name))
2024 .expect("cleanup removes the exact provisioned container");
2025 assert!(removal.contains("rm --force"), "{removal}");
2026 assert!(removal.contains(&name), "{removal}");
2027 }
2028 }
2029
2030 #[test]
2031 fn target_creation_returns_repository_setup_without_running_it() {
2032 let target = podman_target();
2033 let plan =
2034 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2035 let executor = RecordingExecutor::succeeding();
2036
2037 let (_, repositories) =
2038 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2039 Ok(TargetLocator::LocalPodman {
2040 container_id: targets::resource_name(PROVISIONED_SESSION)?,
2041 workspace_storage: Default::default(),
2042 })
2043 })
2044 .unwrap();
2045
2046 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2047 assert!(
2048 repositories
2049 .commands
2050 .iter()
2051 .any(|command| command.purpose == "clone app")
2052 );
2053 }
2054
2055 #[test]
2056 fn a_target_whose_creation_failed_is_never_torn_down() {
2057 for target in container_targets() {
2058 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2059 .unwrap();
2060 let creation = plan.split_at_target_creation().unwrap().0;
2061 let executor =
2062 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2063
2064 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2065 unreachable!("locator discovery must not run after a failed plan")
2066 })
2067 .unwrap_err();
2068
2069 let reported = format!("{error:#}");
2070 assert!(!reported.contains("cleanup"), "{reported}");
2071 assert!(
2072 !executor
2073 .commands()
2074 .iter()
2075 .any(|argv| argv.join(" ").contains("rm --force")),
2076 "{:?}",
2077 executor.commands()
2078 );
2079 }
2080 }
2081
2082 #[test]
2083 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2084 let target = podman_target();
2085 let plan =
2086 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2087 let executor = RecordingExecutor::succeeding();
2088
2089 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2090 bail!("the container never reported an address")
2091 })
2092 .unwrap_err();
2093
2094 let reported = format!("{error:#}");
2095 assert!(reported.contains("never reported an address"), "{reported}");
2096 assert!(reported.contains("cleanup succeeded"), "{reported}");
2097 let removal = executor
2098 .commands()
2099 .into_iter()
2100 .map(|arguments| arguments.join(" "))
2101 .find(|command| command.contains("podman rm --force --ignore"))
2102 .expect("cleanup removes the provisioned Podman container");
2103 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2104 }
2105
2106 #[test]
2109 fn a_bare_project_failure_removes_nothing() {
2110 let target = targets::TargetTemplate::LocalBare;
2111 let plan =
2112 targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2113 .unwrap();
2114 let executor = RecordingExecutor::succeeding();
2115
2116 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2117 bail!("the worker root was unreadable")
2118 })
2119 .unwrap_err();
2120
2121 assert!(!format!("{error:#}").contains("cleanup"));
2122 assert!(executor.commands().is_empty());
2123 }
2124}