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