Skip to main content

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