Skip to main content

mj_controller/controller/
provisioning.rs

1//! Session provisioning, rollback, and worker-side Git bootstrap.
2
3use 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    /// A freshly registered session has no durable history to retain.
48    Discard,
49    /// Resume owns rollback to the archived record and checkpoint lineage.
50    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    /// Start a child worker inside an already-provisioned parent target.
101    /// Repository, target, and mount setup belong exclusively to the parent.
102    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                    // Rollback must remain possible after the foreground
160                    // operation's cancellation token has been set.
161                    .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        // Keep planning, preflight, creation, and locator discovery in one
292        // result so the caller's failure disposition applies to every error.
293        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            // The mounts this container runs with, not the ones the session
319            // stores: a forced downgrade belongs to the host the container
320            // lands on, so it is decided here every time and never written
321            // over the user's choice.
322            for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
323                executor.notify_notice(&notice);
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        // Worker/profile installation is independent of repository cloning.
536        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            // Provisioning has completed, but Running is reserved for a
675            // successful worker handshake.
676            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
752/// Run two independent target setup lanes at the same time and wait for both.
753/// The first lane's failure wins deterministically when both fail, and neither
754/// lane is abandoned while it may still own a transfer or subprocess.
755pub(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/// Run a provisioning plan and discover the locator it produced, tearing the
788/// target down again if anything after its creation fails.
789///
790/// Creation is the boundary that matters. A step that fails before the target
791/// exists has left nothing behind; every failure after it — a later plan step
792/// or locator discovery — owns a target no session record will point at.
793#[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        // Nothing this plan runs can leave a target behind.
803        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
821/// Bring the target into existence and return the commands that populate its
822/// repositories. The caller may overlap that remainder with worker/profile
823/// installation once it has persisted the discovered locator.
824fn 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        // Nothing this plan runs can leave a target behind, so its commands
833        // must still finish before the locator is usable.
834        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
856/// Best-effort teardown of a target whose creation succeeded but whose
857/// provisioning failed before a locator was recorded. Returns a note
858/// describing what happened for inclusion in the session error.
859///
860/// The teardown is the session's own close plan, so a failed launch and an
861/// ordinary close can never disagree about what removing a target means.
862fn 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
915/// The locator a provisioning plan's creating command brought into existence.
916///
917/// Every target but AWS is named before its plan runs; an EC2 instance
918/// reports its own ID in the launch response.
919fn 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        // A bare project directory belongs to the user: provisioning creates
927        // nothing that a failure could leak.
928        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
969/// Attach read-only whatever the selected container overlay cannot hold, and
970/// say so.
971///
972/// The filesystem is probed on the host that runs the container, because that
973/// is where the overlay would be built. A probe that cannot answer leaves the
974/// overlay alone: a failed probe is no evidence of an unsupported filesystem,
975/// and refusing to provision over one would cost the user their session.
976///
977/// Apple's `container` engine already mounts every extra directory read-only,
978/// and EC2 copies the directory instead of mounting it, so neither is probed.
979pub(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
1029/// Reports every command an installer issues as one launch stage, so progress
1030/// stays accurate without threading the stage through each `CommandSpec`.
1031/// A command that already names a stage keeps it.
1032pub(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    /// Answers the filesystem probe, and records every notice provisioning
1211    /// reported while it ran.
1212    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    /// A mount the user already marked read-only has no overlay to protect, so
1437    /// the probe never has to reach a host that may not answer.
1438    #[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    /// Records every command a plan runs, and fails the one whose purpose it
1925    /// was told to fail.
1926    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            // Remote commands reach the target posix-quoted.
1998            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    /// A raw project directory is the user's own: provisioning it creates
2086    /// nothing that a failure could leak.
2087    #[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}