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