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 state.sessions.remove(session_id);
683 Err(error)
684 }
685 }
686}
687
688pub(super) fn apply_failed_new_session_rollback(
689 state: &mut State,
690 session_id: &str,
691 original_error: &str,
692 cleanup_error: Option<String>,
693) -> anyhow::Error {
694 match cleanup_error {
695 None => {
696 state.sessions.remove(session_id);
697 anyhow::anyhow!(
698 "{original_error}; partial target removed and provisional session discarded"
699 )
700 }
701 Some(cleanup_error) => {
702 let failure = format!(
703 "{original_error}; cleanup of the failed session target failed: {cleanup_error}"
704 );
705 let record = state.sessions.get_mut(session_id).unwrap();
706 record.state = SessionState::Error;
707 record.updated_at = now();
708 record.last_error = Some(format!("worker bootstrap failed: {failure}"));
709 anyhow::anyhow!(failure)
710 }
711 }
712}
713
714pub(super) fn install_attached_resources(
715 state: &State,
716 session_id: &str,
717 backend: &targets::TargetLocator,
718 worker_root: &str,
719 executor: &impl CommandExecutor,
720) -> Result<()> {
721 let targets::TargetLocator::AwsEc2 { .. } = backend else {
722 return Ok(());
723 };
724 let session = state
725 .sessions
726 .get(session_id)
727 .with_context(|| format!("unknown session {session_id}"))?;
728 if session.additional_mounts.is_empty() {
729 return Ok(());
730 }
731 for resource in &session.additional_mounts {
732 let install = targets::command_on_locator(
733 backend,
734 session_id,
735 vec![
736 format!("{worker_root}/hel"),
737 "worker".into(),
738 "install-resource".into(),
739 "--destination".into(),
740 resource.destination.to_string_lossy().into_owned(),
741 ],
742 "stream attached resource",
743 )?;
744 mj_checkpoint::resources::stream_resource(&resource.source, |stream| {
745 execute_checked_with_stdin(executor, &install, stream).map(|_| ())
746 })
747 .with_context(|| format!("stream attached resource {}", resource.source.display()))?;
748 }
749 Ok(())
750}
751
752pub(super) fn execute_concurrent_lanes<A: Send, B: Send>(
756 first: impl FnOnce() -> Result<A> + Send,
757 second: impl FnOnce() -> Result<B> + Send,
758) -> Result<(A, B)> {
759 std::thread::scope(|scope| {
760 let second = scope.spawn(second);
761 let first = first();
762 let second = second.join().unwrap_or_else(|panic| {
763 Err(anyhow::anyhow!(
764 "concurrent target lane panicked: {}",
765 targets::command_thread_panic_message(panic.as_ref())
766 ))
767 });
768 match (first, second) {
769 (Err(error), _) => Err(error),
770 (Ok(_), Err(error)) => Err(error),
771 (Ok(first), Ok(second)) => Ok((first, second)),
772 }
773 })
774}
775
776fn execute_repository_setup(
777 plan: &targets::CommandPlan,
778 executor: &(impl CommandExecutor + Sync),
779) -> Result<()> {
780 if plan.commands.is_empty() {
781 return Ok(());
782 }
783 let _cloning = ProvisionStageGuard::new(executor, ProvisionStage::Cloning);
784 plan.execute_concurrent(executor).map(|_| ())
785}
786
787#[cfg(test)]
794fn provision_target(
795 plan: &targets::CommandPlan,
796 target: &targets::TargetTemplate,
797 session_id: &str,
798 executor: &(impl CommandExecutor + Sync),
799 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
800) -> Result<TargetLocator> {
801 let Some((creation, remainder)) = plan.split_at_target_creation() else {
802 return discover(&plan.execute_concurrent(executor)?);
804 };
805 let mut outputs = creation.execute_concurrent(executor)?;
806 let result = match remainder.execute_concurrent(executor) {
807 Ok(rest) => {
808 outputs.extend(rest);
809 discover(&outputs)
810 }
811 Err(error) => Err(error),
812 };
813 result.map_err(|error| {
814 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
815 Some(note) => error.context(note),
816 None => error,
817 }
818 })
819}
820
821fn provision_target_creation(
825 plan: &targets::CommandPlan,
826 target: &targets::TargetTemplate,
827 session_id: &str,
828 executor: &(impl CommandExecutor + Sync),
829 discover: impl FnOnce(&[CommandOutput]) -> Result<TargetLocator>,
830) -> Result<(TargetLocator, targets::CommandPlan)> {
831 let Some((creation, remainder)) = plan.split_at_target_creation() else {
832 let outputs = plan.execute_concurrent(executor)?;
835 return discover(&outputs).map(|locator| {
836 (
837 locator,
838 targets::CommandPlan {
839 description: plan.description.clone(),
840 commands: Vec::new(),
841 },
842 )
843 });
844 };
845 let outputs = creation.execute_concurrent(executor)?;
846 discover(&outputs)
847 .map(|locator| (locator, remainder))
848 .map_err(|error| {
849 match cleanup_failed_provision(target, session_id, outputs.first(), executor) {
850 Some(note) => error.context(note),
851 None => error,
852 }
853 })
854}
855
856fn cleanup_failed_provision(
863 target: &targets::TargetTemplate,
864 session_id: &str,
865 create_output: Option<&CommandOutput>,
866 executor: &impl CommandExecutor,
867) -> Option<String> {
868 let locator = provisioned_locator(target, session_id, create_output)?;
869 let leak = format!(
870 "the resource may still exist; find it via its dev.mj.session={session_id} label/tag"
871 );
872 let plan = match targets::close_plan(&locator, session_id) {
873 Ok(plan) => plan,
874 Err(error) => {
875 tracing::warn!(
876 session_id,
877 error = format!("{error:#}"),
878 "could not build provisioning cleanup plan"
879 );
880 return Some(format!("cleanup FAILED: {error:#}; {leak}"));
881 }
882 };
883 let purpose = plan
884 .commands
885 .iter()
886 .map(|command| command.purpose.clone())
887 .collect::<Vec<_>>()
888 .join("; ");
889 let Err(error) = plan.execute(executor) else {
890 return Some(format!("cleanup succeeded: {purpose}"));
891 };
892 match targets::cleanup_target_is_confirmed_absent(&locator, session_id, executor) {
893 Ok(true) => Some(format!("cleanup succeeded: {purpose}")),
894 Ok(false) => {
895 tracing::warn!(
896 session_id,
897 error = format!("{error:#}"),
898 "provisioning cleanup failed and the target may still exist"
899 );
900 Some(format!("cleanup FAILED ({purpose}): {error:#}; {leak}"))
901 }
902 Err(confirm_error) => {
903 tracing::warn!(
904 session_id,
905 error = format!("{confirm_error:#}"),
906 "could not confirm whether the failed provisioning target was removed"
907 );
908 Some(format!(
909 "cleanup FAILED ({purpose}): {error:#}; checking whether it was removed also failed: {confirm_error:#}; {leak}"
910 ))
911 }
912 }
913}
914
915fn provisioned_locator(
920 target: &targets::TargetTemplate,
921 session_id: &str,
922 create_output: Option<&CommandOutput>,
923) -> Option<targets::TargetLocator> {
924 let container_id = || targets::resource_name(session_id).ok();
925 Some(match target {
926 targets::TargetTemplate::LocalBare => return None,
929 targets::TargetTemplate::LocalPodman(container) => targets::TargetLocator::LocalPodman {
930 container_id: container_id()?,
931 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
932 },
933 targets::TargetTemplate::LocalDocker(_) => targets::TargetLocator::LocalDocker {
934 container_id: container_id()?,
935 },
936 targets::TargetTemplate::AppleContainer(_) => targets::TargetLocator::AppleContainer {
937 container_id: container_id()?,
938 },
939 targets::TargetTemplate::SshPodman { ssh, container } => {
940 targets::TargetLocator::SshPodman {
941 ssh: ssh.clone(),
942 container_id: container_id()?,
943 workspace_storage: targets::podman_workspace_locator(container, session_id).ok()?,
944 }
945 }
946 targets::TargetTemplate::SshDocker { ssh, .. } => targets::TargetLocator::SshDocker {
947 ssh: ssh.clone(),
948 container_id: container_id()?,
949 },
950 targets::TargetTemplate::SshBare { ssh, .. } => targets::TargetLocator::SshBare {
951 ssh: ssh.clone(),
952 workspace: targets::workspace_for(target, session_id).ok()?,
953 worker_id: None,
954 },
955 targets::TargetTemplate::AwsEc2(aws) => targets::TargetLocator::AwsEc2 {
956 profile: aws.profile.clone(),
957 region: aws.region.clone(),
958 instance_id: serde_json::from_slice::<serde_json::Value>(&create_output?.stdout)
959 .ok()?
960 .pointer("/Instances/0/InstanceId")?
961 .as_str()?
962 .to_owned(),
963 ssh: aws.ssh.clone(),
964 workspace: targets::workspace_for(target, session_id).ok()?,
965 },
966 })
967}
968
969pub(super) fn enforce_overlay_capable_mounts(
980 target: &targets::TargetTemplate,
981 mounts: &mut [targets::AdditionalMount],
982 executor: &impl CommandExecutor,
983) -> Vec<String> {
984 let ssh = match target {
985 targets::TargetTemplate::LocalPodman(_) | targets::TargetTemplate::LocalDocker(_) => None,
986 targets::TargetTemplate::SshPodman { ssh, .. }
987 | targets::TargetTemplate::SshDocker { ssh, .. } => Some(ssh),
988 _ => return Vec::new(),
989 };
990 let overlaid = mounts
991 .iter()
992 .filter(|mount| !mount.read_only)
993 .map(|mount| mount.source.clone())
994 .collect::<Vec<_>>();
995 if overlaid.is_empty() {
996 return Vec::new();
997 }
998 let filesystems = match targets::probe_filesystem_types(ssh, &overlaid, executor) {
999 Ok(filesystems) => filesystems,
1000 Err(error) => {
1001 tracing::warn!(
1002 error = format!("{error:#}"),
1003 "could not probe attached-directory filesystems; preserving overlay mounts"
1004 );
1005 return vec![format!(
1006 "Could not read the filesystem under the attached directories, so they keep the \
1007 copy-on-write overlay: {error:#}"
1008 )];
1009 }
1010 };
1011 let mut notices = Vec::new();
1012 for (mount, filesystem) in mounts
1013 .iter_mut()
1014 .filter(|mount| !mount.read_only)
1015 .zip(filesystems)
1016 {
1017 let Some(reason) = targets::overlay_unsupported_filesystem(&filesystem) else {
1018 continue;
1019 };
1020 mount.read_only = true;
1021 notices.push(format!(
1022 "Mounted {} read-only: the overlay is unreliable on {filesystem} ({reason}).",
1023 mount.source.display()
1024 ));
1025 }
1026 notices
1027}
1028
1029pub(super) struct StagedExecutor<'a, E: CommandExecutor> {
1033 inner: &'a E,
1034 stage: ProvisionStage,
1035 _guard: ProvisionStageGuard<'a, E>,
1036}
1037
1038impl<'a, E: CommandExecutor> StagedExecutor<'a, E> {
1039 pub(crate) fn new(inner: &'a E, stage: ProvisionStage) -> Self {
1040 Self {
1041 inner,
1042 stage,
1043 _guard: ProvisionStageGuard::new(inner, stage),
1044 }
1045 }
1046
1047 fn staged(&self, command: &CommandSpec) -> CommandSpec {
1048 if command.stage.is_some() {
1049 return command.clone();
1050 }
1051 command.clone().stage(self.stage)
1052 }
1053}
1054
1055impl<E: CommandExecutor> CommandExecutor for StagedExecutor<'_, E> {
1056 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1057 self.inner.execute(&self.staged(command))
1058 }
1059
1060 fn cancellation_requested(&self) -> bool {
1061 self.inner.cancellation_requested()
1062 }
1063
1064 fn stage_started(&self, stage: ProvisionStage) {
1065 self.inner.stage_started(stage);
1066 }
1067
1068 fn stage_finished(&self, stage: ProvisionStage) {
1069 self.inner.stage_finished(stage);
1070 }
1071
1072 fn notify_notice(&self, notice: &str) {
1073 self.inner.notify_notice(notice);
1074 }
1075
1076 fn execute_with_stdin(
1077 &self,
1078 command: &CommandSpec,
1079 input: &mut (dyn std::io::Read + Send),
1080 ) -> Result<CommandOutput> {
1081 self.inner.execute_with_stdin(&self.staged(command), input)
1082 }
1083}
1084
1085fn execute_checked_with_stdin(
1086 executor: &impl CommandExecutor,
1087 command: &CommandSpec,
1088 input: &mut (dyn std::io::Read + Send),
1089) -> Result<CommandOutput> {
1090 let output = executor.execute_with_stdin(command, input)?;
1091 if output.status != 0 {
1092 bail!(
1093 "{} failed with status {}: {}",
1094 command.purpose,
1095 output.status,
1096 String::from_utf8_lossy(&output.stderr)
1097 );
1098 }
1099 Ok(output)
1100}
1101
1102pub(super) fn install_inherited_git_settings(
1103 executor: &impl CommandExecutor,
1104 locator: &targets::TargetLocator,
1105 session_id: &str,
1106) -> Result<()> {
1107 let settings = if inherits_controller_git_settings(locator) {
1108 controller_git_settings()?
1109 } else {
1110 BTreeMap::new()
1111 };
1112 for command in inherited_git_setting_commands(locator, session_id, settings)? {
1113 execute_checked(executor, command)?;
1114 }
1115 Ok(())
1116}
1117
1118fn inherits_controller_git_settings(locator: &targets::TargetLocator) -> bool {
1119 !matches!(
1120 locator,
1121 targets::TargetLocator::LocalBare { .. } | targets::TargetLocator::SshBare { .. }
1122 )
1123}
1124
1125fn inherited_git_setting_commands(
1126 locator: &targets::TargetLocator,
1127 session_id: &str,
1128 settings: BTreeMap<String, String>,
1129) -> Result<Vec<CommandSpec>> {
1130 if matches!(locator, targets::TargetLocator::SshBare { .. }) {
1131 return Ok(Vec::new());
1132 }
1133 settings
1134 .into_iter()
1135 .map(|(key, value)| {
1136 targets::command_on_locator(
1137 locator,
1138 session_id,
1139 vec![
1140 "git".into(),
1141 "config".into(),
1142 "--global".into(),
1143 "--replace-all".into(),
1144 "--".into(),
1145 key.clone(),
1146 value,
1147 ],
1148 format!("inherit Git setting {key}"),
1149 )
1150 })
1151 .collect()
1152}
1153
1154fn controller_git_settings() -> Result<BTreeMap<String, String>> {
1155 let output = match Command::new("git")
1156 .args(["config", "--global", "--includes", "--null", "--list"])
1157 .stdin(Stdio::null())
1158 .output()
1159 {
1160 Ok(output) => output,
1161 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1162 Err(error) => return Err(error).context("read controller Git configuration"),
1163 };
1164 if !output.status.success() {
1165 bail!(
1166 "read controller Git configuration failed with status {}: {}",
1167 output.status,
1168 String::from_utf8_lossy(&output.stderr).trim()
1169 );
1170 }
1171 parse_inherited_git_settings(&output.stdout)
1172}
1173
1174fn parse_inherited_git_settings(output: &[u8]) -> Result<BTreeMap<String, String>> {
1175 let mut settings = BTreeMap::new();
1176 for entry in output
1177 .split(|byte| *byte == 0)
1178 .filter(|entry| !entry.is_empty())
1179 {
1180 let entry = std::str::from_utf8(entry).context("decode controller Git configuration")?;
1181 let (key, value) = entry
1182 .split_once('\n')
1183 .with_context(|| format!("controller Git returned malformed entry {entry:?}"))?;
1184 let key = key.to_ascii_lowercase();
1185 if INHERITED_GIT_SETTINGS.contains(&key.as_str()) {
1186 settings.insert(key, value.to_owned());
1187 }
1188 }
1189 Ok(settings)
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use std::collections::BTreeMap;
1195
1196 use std::sync::Mutex;
1197
1198 use mj_core::config::{
1199 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1200 ProjectRepository, SshConnection,
1201 };
1202 use mj_core::state::{SessionRecord, SessionState, State, TargetLocator};
1203
1204 use crate::targets::{self, AdditionalMount, ContainerTemplate, ProjectBundleSpec, SshTarget};
1205
1206 use crate::controller::SessionLaunchOptions;
1207
1208 use super::*;
1209
1210 struct ProbeExecutor {
1213 answer: std::result::Result<&'static str, &'static str>,
1214 notices: Mutex<Vec<String>>,
1215 }
1216
1217 impl ProbeExecutor {
1218 fn answering(answer: &'static str) -> Self {
1219 Self {
1220 answer: Ok(answer),
1221 notices: Mutex::new(Vec::new()),
1222 }
1223 }
1224
1225 fn failing(stderr: &'static str) -> Self {
1226 Self {
1227 answer: Err(stderr),
1228 notices: Mutex::new(Vec::new()),
1229 }
1230 }
1231 }
1232
1233 impl CommandExecutor for ProbeExecutor {
1234 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1235 assert_eq!(command.program, "stat", "only the probe may run here");
1236 Ok(match self.answer {
1237 Ok(filesystem) => CommandOutput {
1238 status: 0,
1239 stdout: format!("{filesystem}\n").into_bytes(),
1240 stderr: Vec::new(),
1241 },
1242 Err(stderr) => CommandOutput {
1243 status: 1,
1244 stdout: Vec::new(),
1245 stderr: stderr.as_bytes().to_vec(),
1246 },
1247 })
1248 }
1249
1250 fn notify_notice(&self, notice: &str) {
1251 self.notices.lock().unwrap().push(notice.to_owned());
1252 }
1253 }
1254
1255 fn podman_target() -> targets::TargetTemplate {
1256 targets::TargetTemplate::LocalPodman(ContainerTemplate {
1257 image: "ubuntu:24.04".into(),
1258 pull_policy: Default::default(),
1259 extra_run_args: Vec::new(),
1260 workspace_storage: Default::default(),
1261 })
1262 }
1263
1264 fn probe_bundle() -> ProjectBundleSpec {
1265 ProjectBundleSpec {
1266 primary: "app".into(),
1267 repositories: vec![crate::targets::RepositorySpec {
1268 url: Some("https://github.com/example/app.git".into()),
1269 push_urls: Vec::new(),
1270 destination: "app".into(),
1271 git_ref: None,
1272 reference: None,
1273 }],
1274 }
1275 }
1276
1277 fn ssh_docker_registration_config() -> Config {
1278 let mut config = Config::default();
1279 config.profiles.insert(
1280 "codex".into(),
1281 HarnessProfile {
1282 enabled: true,
1283 kind: HarnessKind::Codex,
1284 home: PathBuf::from("/home/dev/.codex"),
1285 environment: BTreeMap::new(),
1286 context_window_bytes: None,
1287 },
1288 );
1289 config.bundles.insert(
1290 "project".into(),
1291 ProjectBundle {
1292 primary_repo: "project".into(),
1293 repositories: vec![ProjectRepository {
1294 id: "project".into(),
1295 github: Some("owner/project".into()),
1296 local: None,
1297 destination: PathBuf::from("project"),
1298 git_ref: None,
1299 }],
1300 },
1301 );
1302 config.targets.insert(
1303 "docker".into(),
1304 TargetTemplate::SshDocker {
1305 ssh: SshConnection {
1306 host: "builder".into(),
1307 user: Some("agent".into()),
1308 identity_file: None,
1309 extra_args: Vec::new(),
1310 },
1311 container: ConfigContainer {
1312 image: "failimage:never".into(),
1313 pull_policy: Default::default(),
1314 platform: None,
1315 cpus: None,
1316 memory: None,
1317 environment: BTreeMap::new(),
1318 workspace_storage: Default::default(),
1319 },
1320 },
1321 );
1322 config
1323 }
1324
1325 #[test]
1326 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1327 let executor = ProbeExecutor::answering("nfs");
1328 let mut mounts = vec![AdditionalMount {
1329 source: PathBuf::from("/nfs/share"),
1330 destination: PathBuf::from("/mnt/share"),
1331 read_only: false,
1332 }];
1333
1334 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1335
1336 assert!(mounts[0].read_only);
1337 assert_eq!(notices.len(), 1);
1338 assert!(
1339 notices[0]
1340 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1341 "{notices:?}"
1342 );
1343 let plan = targets::provision_plan(
1344 &podman_target(),
1345 "0123456789abcdef0123456789abcdef",
1346 &probe_bundle(),
1347 &mounts,
1348 )
1349 .unwrap();
1350 assert!(
1351 plan.commands[0]
1352 .args
1353 .windows(2)
1354 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1355 "{:?}",
1356 plan.commands[0].args
1357 );
1358 }
1359
1360 #[test]
1361 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1362 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1363 let mut mounts = vec![AdditionalMount {
1364 source: PathBuf::from("/host/cache"),
1365 destination: PathBuf::from("/mnt/cache"),
1366 read_only: false,
1367 }];
1368
1369 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1370
1371 assert!(!mounts[0].read_only);
1372 assert_eq!(notices.len(), 1);
1373 assert!(
1374 notices[0].contains("keep the copy-on-write overlay")
1375 && notices[0].contains("cannot read file system information"),
1376 "{notices:?}"
1377 );
1378 let plan = targets::provision_plan(
1379 &podman_target(),
1380 "0123456789abcdef0123456789abcdef",
1381 &probe_bundle(),
1382 &mounts,
1383 )
1384 .unwrap();
1385 assert!(
1386 plan.commands[0]
1387 .args
1388 .windows(2)
1389 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1390 "{:?}",
1391 plan.commands[0].args
1392 );
1393 }
1394
1395 #[test]
1396 fn engines_without_an_overlay_to_lose_are_never_probed() {
1397 struct UnusedExecutor;
1398
1399 impl CommandExecutor for UnusedExecutor {
1400 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1401 panic!("this target must not probe: {}", command.program)
1402 }
1403 }
1404
1405 let mut mounts = vec![AdditionalMount {
1406 source: PathBuf::from("/host/cache"),
1407 destination: PathBuf::from("/mnt/cache"),
1408 read_only: false,
1409 }];
1410 for target in [
1411 targets::TargetTemplate::AppleContainer(ContainerTemplate {
1412 image: "ubuntu:24.04".into(),
1413 pull_policy: Default::default(),
1414 extra_run_args: Vec::new(),
1415 workspace_storage: Default::default(),
1416 }),
1417 targets::TargetTemplate::AwsEc2(targets::AwsTemplate {
1418 profile: "default".into(),
1419 region: "us-east-1".into(),
1420 launch_template: "lt-0123456789abcdef0".into(),
1421 launch_template_version: None,
1422 instance_type: None,
1423 ssh: SshTarget {
1424 destination: "ubuntu@example.test".into(),
1425 ssh_args: Vec::new(),
1426 },
1427 }),
1428 ] {
1429 assert!(
1430 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1431 );
1432 assert!(!mounts[0].read_only);
1433 }
1434 }
1435
1436 #[test]
1439 fn mounts_already_read_only_are_not_probed() {
1440 struct UnusedExecutor;
1441
1442 impl CommandExecutor for UnusedExecutor {
1443 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1444 panic!("a read-only mount must not probe: {}", command.program)
1445 }
1446 }
1447
1448 let mut mounts = vec![AdditionalMount {
1449 source: PathBuf::from("/host/cache"),
1450 destination: PathBuf::from("/mnt/cache"),
1451 read_only: true,
1452 }];
1453
1454 assert!(
1455 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1456 .is_empty()
1457 );
1458 }
1459
1460 #[test]
1461 fn failed_new_session_provisioning_discards_provisional_record() {
1462 let session_id = "0123456789abcdef0123456789abcdef";
1463 let record = SessionRecord {
1464 create_managed_worktree: None,
1465 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1466 archived: false,
1467 container_cpus: None,
1468 container_memory: None,
1469 id: session_id.into(),
1470 title: "new session".into(),
1471 harness_kind: mj_core::config::HarnessKind::Codex,
1472 last_profile: "codex".into(),
1473 bundle_id: "project".into(),
1474 project_directory: None,
1475 managed_worktree: None,
1476 target_template_id: "podman".into(),
1477 resource_allocation: None,
1478 additional_mounts: Vec::new(),
1479 state: SessionState::Provisioning,
1480 target: None,
1481 native_session_id: None,
1482 acp_session_title: None,
1483 session_title_override: None,
1484 created_at: "2026-08-12T00:00:00Z".into(),
1485 updated_at: "2026-08-12T00:00:00Z".into(),
1486 viewed_through_event_ordinal: 0,
1487 draft_input: String::new(),
1488 last_error: None,
1489 last_checkpoint_error: None,
1490 checkpoint: None,
1491 };
1492 let mut state = State::default();
1493 state.sessions.insert(session_id.into(), record);
1494
1495 let result = apply_new_session_provisioning_result(
1496 &mut state,
1497 session_id,
1498 Err(anyhow::anyhow!("container creation failed")),
1499 );
1500
1501 assert!(result.is_err());
1502 assert!(!state.sessions.contains_key(session_id));
1503 }
1504
1505 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
1506
1507 #[test]
1508 fn failed_ssh_docker_preflight_removes_durable_provisioning_record() {
1509 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1510 let directory = tempfile::tempdir().unwrap();
1511 let test = "failed_ssh_docker_preflight_removes_durable_provisioning_record";
1512 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1513 command
1514 .args([
1515 "--exact",
1516 &format!("controller::provisioning::tests::{test}"),
1517 "--nocapture",
1518 ])
1519 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1520 .env("MJ_DATA_DIR", directory.path())
1521 .env("MJ_CONFIG_DIR", directory.path());
1522 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1523 assert!(
1524 output.status.success(),
1525 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1526 String::from_utf8_lossy(&output.stdout),
1527 String::from_utf8_lossy(&output.stderr)
1528 );
1529 return;
1530 }
1531
1532 let _writer = crate::database::install_isolated_test_writer();
1533 let config = ssh_docker_registration_config();
1534 config.save().unwrap();
1535 let mut controller = Controller {
1536 config,
1537 state: State::default(),
1538 };
1539 let session_id = controller
1540 .register_session_with_resources(
1541 "codex",
1542 "project",
1543 "docker",
1544 "failed image",
1545 SessionLaunchOptions {
1546 create_managed_worktree: None,
1547 initial_prompt: None,
1548 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1549 additional_mounts: Vec::new(),
1550 allow_dirty_local: false,
1551 resource_allocation: None,
1552 project_directory: None,
1553 session_title_override: None,
1554 },
1555 )
1556 .unwrap();
1557 assert!(
1558 crate::database::load_state()
1559 .unwrap()
1560 .sessions
1561 .contains_key(&session_id)
1562 );
1563
1564 let executor = RecordingExecutor::failing("check Docker daemon");
1565 let error =
1566 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1567 &session_id,
1568 &executor,
1569 None,
1570 ProvisioningFailureDisposition::Discard,
1571 ))
1572 .unwrap_err();
1573 let reported = format!("{error:#}");
1574 assert!(
1575 reported.contains("remote Docker preflight failed"),
1576 "{reported}"
1577 );
1578 assert!(
1579 executor.commands().iter().any(|argv| {
1580 let command = argv.join(" ");
1581 command.contains("'docker' 'version'")
1582 }),
1583 "the fake preflight did not run: {:?}",
1584 executor.commands()
1585 );
1586 assert!(!controller.state.sessions.contains_key(&session_id));
1587
1588 let reloaded = Controller::load().unwrap();
1589 assert!(
1590 !reloaded.state.sessions.contains_key(&session_id),
1591 "failed SSH Docker launch left a durable provisioning row"
1592 );
1593 }
1594
1595 #[test]
1596 fn failed_node_preflight_discards_session_before_provisioning() {
1597 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1598 let directory = tempfile::tempdir().unwrap();
1599 let test = "failed_node_preflight_discards_session_before_provisioning";
1600 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1601 command
1602 .args([
1603 "--exact",
1604 &format!("controller::provisioning::tests::{test}"),
1605 "--nocapture",
1606 ])
1607 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1608 .env("MJ_DATA_DIR", directory.path())
1609 .env("MJ_CONFIG_DIR", directory.path());
1610 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1611 assert!(
1612 output.status.success(),
1613 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1614 String::from_utf8_lossy(&output.stdout),
1615 String::from_utf8_lossy(&output.stderr)
1616 );
1617 return;
1618 }
1619
1620 let _writer = crate::database::install_isolated_test_writer();
1621 let mut config = ssh_docker_registration_config();
1622 config.targets.insert(
1623 "docker".into(),
1624 TargetTemplate::SshBare {
1625 ssh: SshConnection {
1626 host: "builder".into(),
1627 user: Some("agent".into()),
1628 identity_file: None,
1629 extra_args: Vec::new(),
1630 },
1631 permissions: mj_core::config::PermissionMode::Guardian,
1632 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
1633 },
1634 );
1635 config.save().unwrap();
1636 let mut controller = Controller {
1637 config,
1638 state: State::default(),
1639 };
1640 let session_id = controller
1641 .register_session_with_resources(
1642 "codex",
1643 "project",
1644 "docker",
1645 "missing Node",
1646 SessionLaunchOptions {
1647 create_managed_worktree: None,
1648 initial_prompt: None,
1649 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1650 additional_mounts: Vec::new(),
1651 allow_dirty_local: false,
1652 resource_allocation: None,
1653 project_directory: Some("/srv/project".into()),
1654 session_title_override: None,
1655 },
1656 )
1657 .unwrap();
1658 assert!(
1659 crate::database::load_state()
1660 .unwrap()
1661 .sessions
1662 .contains_key(&session_id)
1663 );
1664
1665 let executor = RecordingExecutor::failing("preflight managed harness Node.js and npm");
1666 let error =
1667 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1668 &session_id,
1669 &executor,
1670 None,
1671 ProvisioningFailureDisposition::Discard,
1672 ))
1673 .unwrap_err();
1674 let reported = format!("{error:#}");
1675 assert!(reported.contains("Node.js 22+ and npm"), "{reported}");
1676 assert_eq!(
1677 executor.commands().len(),
1678 1,
1679 "preflight must fail before provisioning"
1680 );
1681 assert!(!controller.state.sessions.contains_key(&session_id));
1682
1683 let reloaded = Controller::load().unwrap();
1684 assert!(
1685 !reloaded.state.sessions.contains_key(&session_id),
1686 "failed Node preflight left a durable provisioning row"
1687 );
1688 }
1689
1690 #[test]
1691 fn failed_new_worker_start_discards_session_only_after_target_cleanup() {
1692 let session_id = "0123456789abcdef0123456789abcdef";
1693 let mut session = SessionRecord {
1694 create_managed_worktree: None,
1695 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1696 archived: false,
1697 container_cpus: None,
1698 container_memory: None,
1699 id: session_id.into(),
1700 title: "new session".into(),
1701 harness_kind: mj_core::config::HarnessKind::Kimi,
1702 last_profile: "kimi".into(),
1703 bundle_id: "raw-project".into(),
1704 project_directory: Some("/srv/project".into()),
1705 managed_worktree: None,
1706 target_template_id: "remote".into(),
1707 resource_allocation: None,
1708 additional_mounts: Vec::new(),
1709 state: SessionState::Disconnected,
1710 target: Some(TargetLocator::SshBare {
1711 host: "builder".into(),
1712 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
1713 worker_id: None,
1714 }),
1715 native_session_id: None,
1716 acp_session_title: None,
1717 session_title_override: None,
1718 created_at: "2026-08-12T00:00:00Z".into(),
1719 updated_at: "2026-08-12T00:00:00Z".into(),
1720 viewed_through_event_ordinal: 0,
1721 draft_input: String::new(),
1722 last_error: None,
1723 last_checkpoint_error: None,
1724 checkpoint: None,
1725 };
1726 let mut cleaned = State::default();
1727 cleaned.sessions.insert(session_id.into(), session.clone());
1728
1729 let failure =
1730 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
1731
1732 assert!(!cleaned.sessions.contains_key(session_id));
1733 assert!(
1734 failure
1735 .to_string()
1736 .contains("provisional session discarded")
1737 );
1738
1739 session.state = SessionState::Disconnected;
1740 let mut cleanup_failed = State::default();
1741 cleanup_failed.sessions.insert(session_id.into(), session);
1742 let failure = apply_failed_new_session_rollback(
1743 &mut cleanup_failed,
1744 session_id,
1745 "ACP startup failed",
1746 Some("ssh unavailable".into()),
1747 );
1748 let retained = cleanup_failed.sessions.get(session_id).unwrap();
1749 assert_eq!(retained.state, SessionState::Error);
1750 assert!(retained.target.is_some());
1751 assert!(failure.to_string().contains("cleanup"));
1752 }
1753 #[test]
1754 fn launch_failure_is_persisted_separately_from_session_state() {
1755 let directory = tempfile::tempdir().unwrap();
1756 let session_id = "0123456789abcdef0123456789abcdef";
1757 let detail = format!(
1758 "specific startup cause\n{}\nstderr tail survives",
1759 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
1760 );
1761
1762 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
1763 let saved = std::fs::read_to_string(path).unwrap();
1764
1765 assert!(saved.contains("specific startup cause"));
1766 assert!(saved.contains("launch diagnostic truncated"));
1767 assert!(saved.contains("stderr tail survives"));
1768 #[cfg(unix)]
1769 {
1770 use std::os::unix::fs::PermissionsExt;
1771 assert_eq!(
1772 std::fs::metadata(directory.path())
1773 .unwrap()
1774 .permissions()
1775 .mode()
1776 & 0o777,
1777 0o700
1778 );
1779 }
1780 }
1781 #[test]
1782 fn inherited_git_settings_allow_only_portable_non_executable_values() {
1783 let settings = parse_inherited_git_settings(
1784 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",
1785 )
1786 .unwrap();
1787
1788 assert_eq!(
1789 settings,
1790 BTreeMap::from([
1791 ("pull.rebase".into(), "true".into()),
1792 ("user.email".into(), "agent@example.test".into()),
1793 ("user.name".into(), "Final User".into()),
1794 ])
1795 );
1796 }
1797 #[test]
1798 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
1799 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
1800 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
1801 }
1802 #[test]
1803 fn inherited_git_settings_target_only_isolated_workers() {
1804 let ssh = SshTarget {
1805 destination: "worker@example.test".into(),
1806 ssh_args: vec!["-p".into(), "2222".into()],
1807 };
1808 let ephemeral = [
1809 targets::TargetLocator::LocalPodman {
1810 container_id: "abcdef012345".into(),
1811 workspace_storage: Default::default(),
1812 },
1813 targets::TargetLocator::AppleContainer {
1814 container_id: "abcdef012346".into(),
1815 },
1816 targets::TargetLocator::AwsEc2 {
1817 profile: "default".into(),
1818 region: "us-east-1".into(),
1819 instance_id: "i-1234567890abcdef0".into(),
1820 ssh: ssh.clone(),
1821 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
1822 .into(),
1823 },
1824 targets::TargetLocator::SshPodman {
1825 ssh: ssh.clone(),
1826 container_id: "abcdef012347".into(),
1827 workspace_storage: Default::default(),
1828 },
1829 ];
1830 for locator in &ephemeral {
1831 assert!(inherits_controller_git_settings(locator));
1832 let commands = inherited_git_setting_commands(
1833 locator,
1834 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1835 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
1836 )
1837 .unwrap();
1838 assert_eq!(commands.len(), 1);
1839 assert!(
1840 commands[0]
1841 .args
1842 .iter()
1843 .any(|argument| argument.contains("user.name"))
1844 );
1845 assert!(
1846 commands[0]
1847 .args
1848 .iter()
1849 .any(|argument| argument.contains("- Agent O'"))
1850 );
1851 }
1852
1853 let persistent = targets::TargetLocator::SshBare {
1854 worker_id: None,
1855 ssh,
1856 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1857 };
1858 let local = targets::TargetLocator::LocalBare {
1859 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1860 };
1861 assert!(!inherits_controller_git_settings(&persistent));
1862 assert!(!inherits_controller_git_settings(&local));
1863 assert!(
1864 inherited_git_setting_commands(
1865 &persistent,
1866 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1867 BTreeMap::from([("user.name".into(), "Agent".into())]),
1868 )
1869 .unwrap()
1870 .is_empty()
1871 );
1872 }
1873
1874 #[test]
1875 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
1876 let ssh = mj_core::config::SshConnection {
1877 host: "builder".into(),
1878 user: None,
1879 identity_file: None,
1880 extra_args: Vec::new(),
1881 };
1882 let guardian = TargetTemplate::SshBare {
1883 ssh: ssh.clone(),
1884 permissions: mj_core::config::PermissionMode::Guardian,
1885 workspace_prefix: ".local/share/hel/workspaces".into(),
1886 };
1887 let podman = TargetTemplate::SshPodman {
1888 ssh: ssh.clone(),
1889 container: mj_core::config::ContainerTemplate {
1890 image: "example.invalid/agent:latest".into(),
1891 pull_policy: Default::default(),
1892 platform: None,
1893 cpus: None,
1894 memory: None,
1895 environment: BTreeMap::new(),
1896 workspace_storage: Default::default(),
1897 },
1898 };
1899 let yolo = TargetTemplate::SshBare {
1900 ssh,
1901 permissions: mj_core::config::PermissionMode::Yolo,
1902 workspace_prefix: ".local/share/hel/workspaces".into(),
1903 };
1904
1905 assert_eq!(
1906 TargetTemplate::LocalBare.execution_policy(),
1907 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1908 );
1909 assert_eq!(
1910 guardian.execution_policy(),
1911 mj_core::config::ExecutionPolicy::ConfiguredApprovals
1912 );
1913 assert_eq!(
1914 podman.execution_policy(),
1915 mj_core::config::ExecutionPolicy::Unconstrained
1916 );
1917 assert_eq!(
1918 yolo.execution_policy(),
1919 mj_core::config::ExecutionPolicy::Unconstrained
1920 );
1921 }
1922 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
1923
1924 struct RecordingExecutor {
1927 failing_purpose: String,
1928 commands: Mutex<Vec<Vec<String>>>,
1929 }
1930
1931 impl RecordingExecutor {
1932 fn failing(purpose: impl Into<String>) -> Self {
1933 Self {
1934 failing_purpose: purpose.into(),
1935 commands: Mutex::new(Vec::new()),
1936 }
1937 }
1938
1939 fn succeeding() -> Self {
1940 Self::failing(String::new())
1941 }
1942
1943 fn commands(&self) -> Vec<Vec<String>> {
1944 self.commands.lock().unwrap().clone()
1945 }
1946 }
1947
1948 impl CommandExecutor for RecordingExecutor {
1949 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1950 let mut argv = vec![command.program.clone()];
1951 argv.extend(command.args.clone());
1952 self.commands.lock().unwrap().push(argv);
1953 Ok(CommandOutput {
1954 status: i32::from(command.purpose == self.failing_purpose),
1955 stdout: Vec::new(),
1956 stderr: b"the step failed".to_vec(),
1957 })
1958 }
1959 }
1960
1961 fn container_targets() -> Vec<targets::TargetTemplate> {
1962 let container = ContainerTemplate {
1963 image: "ubuntu:24.04".into(),
1964 pull_policy: Default::default(),
1965 extra_run_args: Vec::new(),
1966 workspace_storage: Default::default(),
1967 };
1968 vec![
1969 targets::TargetTemplate::LocalPodman(container.clone()),
1970 targets::TargetTemplate::AppleContainer(container.clone()),
1971 targets::TargetTemplate::SshPodman {
1972 ssh: SshTarget {
1973 destination: "dev@example.test".into(),
1974 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
1975 },
1976 container,
1977 },
1978 ]
1979 }
1980
1981 #[test]
1982 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
1983 let name = targets::resource_name(PROVISIONED_SESSION).unwrap();
1984 for target in container_targets() {
1985 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
1986 .unwrap();
1987 let executor = RecordingExecutor::failing("clone app");
1988
1989 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1990 unreachable!("locator discovery must not run after a failed plan")
1991 })
1992 .unwrap_err();
1993
1994 let reported = format!("{error:#}");
1995 assert!(reported.contains("clone app failed"), "{reported}");
1996 assert!(reported.contains("cleanup succeeded"), "{reported}");
1997 let removal = executor
1999 .commands()
2000 .into_iter()
2001 .map(|arguments| arguments.join(" ").replace('\'', ""))
2002 .find(|command| command.contains("rm --force") && command.contains(&name))
2003 .expect("cleanup removes the exact provisioned container");
2004 assert!(removal.contains("rm --force"), "{removal}");
2005 assert!(removal.contains(&name), "{removal}");
2006 }
2007 }
2008
2009 #[test]
2010 fn target_creation_returns_repository_setup_without_running_it() {
2011 let target = podman_target();
2012 let plan =
2013 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2014 let executor = RecordingExecutor::succeeding();
2015
2016 let (_, repositories) =
2017 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2018 Ok(TargetLocator::LocalPodman {
2019 container_id: targets::resource_name(PROVISIONED_SESSION)?,
2020 workspace_storage: Default::default(),
2021 })
2022 })
2023 .unwrap();
2024
2025 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
2026 assert!(
2027 repositories
2028 .commands
2029 .iter()
2030 .any(|command| command.purpose == "clone app")
2031 );
2032 }
2033
2034 #[test]
2035 fn a_target_whose_creation_failed_is_never_torn_down() {
2036 for target in container_targets() {
2037 let plan = targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
2038 .unwrap();
2039 let creation = plan.split_at_target_creation().unwrap().0;
2040 let executor =
2041 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
2042
2043 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2044 unreachable!("locator discovery must not run after a failed plan")
2045 })
2046 .unwrap_err();
2047
2048 let reported = format!("{error:#}");
2049 assert!(!reported.contains("cleanup"), "{reported}");
2050 assert!(
2051 !executor
2052 .commands()
2053 .iter()
2054 .any(|argv| argv.join(" ").contains("rm --force")),
2055 "{:?}",
2056 executor.commands()
2057 );
2058 }
2059 }
2060
2061 #[test]
2062 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
2063 let target = podman_target();
2064 let plan =
2065 targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[]).unwrap();
2066 let executor = RecordingExecutor::succeeding();
2067
2068 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2069 bail!("the container never reported an address")
2070 })
2071 .unwrap_err();
2072
2073 let reported = format!("{error:#}");
2074 assert!(reported.contains("never reported an address"), "{reported}");
2075 assert!(reported.contains("cleanup succeeded"), "{reported}");
2076 let removal = executor
2077 .commands()
2078 .into_iter()
2079 .map(|arguments| arguments.join(" "))
2080 .find(|command| command.contains("podman rm --force --ignore"))
2081 .expect("cleanup removes the provisioned Podman container");
2082 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
2083 }
2084
2085 #[test]
2088 fn a_bare_project_failure_removes_nothing() {
2089 let target = targets::TargetTemplate::LocalBare;
2090 let plan =
2091 targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
2092 .unwrap();
2093 let executor = RecordingExecutor::succeeding();
2094
2095 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
2096 bail!("the worker root was unreadable")
2097 })
2098 .unwrap_err();
2099
2100 assert!(!format!("{error:#}").contains("cleanup"));
2101 assert!(executor.commands().is_empty());
2102 }
2103}