1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail, ensure};
9
10use 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 Discard,
48 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 .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 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 for notice in enforce_overlay_capable_mounts(&target, &mut runtime_mounts, executor) {
279 executor.notify_notice(¬ice);
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 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 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
705pub(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#[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 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
774fn 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 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
809fn 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
868fn 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 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
929pub(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
990pub(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 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 #[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 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 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 #[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}