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 kind: HarnessKind::Codex,
1231 home: PathBuf::from("/home/dev/.codex"),
1232 environment: BTreeMap::new(),
1233 context_window_bytes: None,
1234 },
1235 );
1236 config.bundles.insert(
1237 "project".into(),
1238 ProjectBundle {
1239 primary_repo: "project".into(),
1240 repositories: vec![ProjectRepository {
1241 id: "project".into(),
1242 github: Some("owner/project".into()),
1243 local: None,
1244 destination: PathBuf::from("project"),
1245 git_ref: None,
1246 }],
1247 },
1248 );
1249 config.targets.insert(
1250 "docker".into(),
1251 TargetTemplate::SshDocker {
1252 ssh: SshConnection {
1253 host: "builder".into(),
1254 user: Some("agent".into()),
1255 identity_file: None,
1256 extra_args: Vec::new(),
1257 },
1258 container: ConfigContainer {
1259 image: "failimage:never".into(),
1260 pull_policy: Default::default(),
1261 platform: None,
1262 cpus: None,
1263 memory: None,
1264 environment: BTreeMap::new(),
1265 workspace_storage: Default::default(),
1266 },
1267 },
1268 );
1269 config
1270 }
1271
1272 #[test]
1273 fn a_source_that_cannot_overlay_is_mounted_read_only_and_reported() {
1274 let executor = ProbeExecutor::answering("nfs");
1275 let mut mounts = vec![AdditionalMount {
1276 source: PathBuf::from("/nfs/share"),
1277 destination: PathBuf::from("/mnt/share"),
1278 read_only: false,
1279 }];
1280
1281 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1282
1283 assert!(mounts[0].read_only);
1284 assert_eq!(notices.len(), 1);
1285 assert!(
1286 notices[0]
1287 .contains("Mounted /nfs/share read-only: the overlay is unreliable on nfs (network filesystem)"),
1288 "{notices:?}"
1289 );
1290 let plan = hel_targets::provision_plan(
1291 &podman_target(),
1292 "0123456789abcdef0123456789abcdef",
1293 &probe_bundle(),
1294 &mounts,
1295 )
1296 .unwrap();
1297 assert!(
1298 plan.commands[0]
1299 .args
1300 .windows(2)
1301 .any(|args| args == ["--volume", "/nfs/share:/mnt/share:ro"]),
1302 "{:?}",
1303 plan.commands[0].args
1304 );
1305 }
1306
1307 #[test]
1308 fn a_probe_that_cannot_answer_keeps_the_overlay_and_says_so() {
1309 let executor = ProbeExecutor::failing("stat: cannot read file system information");
1310 let mut mounts = vec![AdditionalMount {
1311 source: PathBuf::from("/host/cache"),
1312 destination: PathBuf::from("/mnt/cache"),
1313 read_only: false,
1314 }];
1315
1316 let notices = enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &executor);
1317
1318 assert!(!mounts[0].read_only);
1319 assert_eq!(notices.len(), 1);
1320 assert!(
1321 notices[0].contains("keep the copy-on-write overlay")
1322 && notices[0].contains("cannot read file system information"),
1323 "{notices:?}"
1324 );
1325 let plan = hel_targets::provision_plan(
1326 &podman_target(),
1327 "0123456789abcdef0123456789abcdef",
1328 &probe_bundle(),
1329 &mounts,
1330 )
1331 .unwrap();
1332 assert!(
1333 plan.commands[0]
1334 .args
1335 .windows(2)
1336 .any(|args| args == ["--volume", "/host/cache:/mnt/cache:O"]),
1337 "{:?}",
1338 plan.commands[0].args
1339 );
1340 }
1341
1342 #[test]
1343 fn engines_without_an_overlay_to_lose_are_never_probed() {
1344 struct UnusedExecutor;
1345
1346 impl CommandExecutor for UnusedExecutor {
1347 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1348 panic!("this target must not probe: {}", command.program)
1349 }
1350 }
1351
1352 let mut mounts = vec![AdditionalMount {
1353 source: PathBuf::from("/host/cache"),
1354 destination: PathBuf::from("/mnt/cache"),
1355 read_only: false,
1356 }];
1357 for target in [
1358 hel_targets::TargetTemplate::AppleContainer(ContainerTemplate {
1359 image: "ubuntu:24.04".into(),
1360 pull_policy: Default::default(),
1361 extra_run_args: Vec::new(),
1362 workspace_storage: Default::default(),
1363 }),
1364 hel_targets::TargetTemplate::AwsEc2(hel_targets::AwsTemplate {
1365 profile: "default".into(),
1366 region: "us-east-1".into(),
1367 launch_template: "lt-0123456789abcdef0".into(),
1368 launch_template_version: None,
1369 instance_type: None,
1370 ssh: SshTarget {
1371 destination: "ubuntu@example.test".into(),
1372 ssh_args: Vec::new(),
1373 },
1374 }),
1375 ] {
1376 assert!(
1377 enforce_overlay_capable_mounts(&target, &mut mounts, &UnusedExecutor).is_empty()
1378 );
1379 assert!(!mounts[0].read_only);
1380 }
1381 }
1382
1383 #[test]
1386 fn mounts_already_read_only_are_not_probed() {
1387 struct UnusedExecutor;
1388
1389 impl CommandExecutor for UnusedExecutor {
1390 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1391 panic!("a read-only mount must not probe: {}", command.program)
1392 }
1393 }
1394
1395 let mut mounts = vec![AdditionalMount {
1396 source: PathBuf::from("/host/cache"),
1397 destination: PathBuf::from("/mnt/cache"),
1398 read_only: true,
1399 }];
1400
1401 assert!(
1402 enforce_overlay_capable_mounts(&podman_target(), &mut mounts, &UnusedExecutor)
1403 .is_empty()
1404 );
1405 }
1406
1407 #[test]
1408 fn failed_new_session_provisioning_discards_provisional_record() {
1409 let session_id = "0123456789abcdef0123456789abcdef";
1410 let record = SessionRecord {
1411 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1412 archived: false,
1413 container_cpus: None,
1414 container_memory: None,
1415 id: session_id.into(),
1416 title: "new session".into(),
1417 harness_kind: hel::hel_config::HarnessKind::Codex,
1418 last_profile: "codex".into(),
1419 bundle_id: "project".into(),
1420 project_directory: None,
1421 managed_worktree: None,
1422 target_template_id: "podman".into(),
1423 resource_allocation: None,
1424 additional_mounts: Vec::new(),
1425 state: SessionState::Provisioning,
1426 target: None,
1427 native_session_id: None,
1428 acp_session_title: None,
1429 session_title_override: None,
1430 created_at: "2026-08-12T00:00:00Z".into(),
1431 updated_at: "2026-08-12T00:00:00Z".into(),
1432 viewed_through_event_ordinal: 0,
1433 draft_input: String::new(),
1434 last_error: None,
1435 last_checkpoint_error: None,
1436 checkpoint: None,
1437 };
1438 let mut state = HelState::default();
1439 state.sessions.insert(session_id.into(), record);
1440
1441 let result = apply_new_session_provisioning_result(
1442 &mut state,
1443 session_id,
1444 Err(anyhow::anyhow!("container creation failed")),
1445 );
1446
1447 assert!(result.is_err());
1448 assert!(!state.sessions.contains_key(session_id));
1449 }
1450
1451 const SSH_DOCKER_FAILURE_CHILD: &str = "MJ_TEST_SSH_DOCKER_FAILURE_CHILD";
1452
1453 #[test]
1454 fn failed_ssh_docker_preflight_removes_durable_provisioning_record() {
1455 if std::env::var_os(SSH_DOCKER_FAILURE_CHILD).is_none() {
1456 let directory = tempfile::tempdir().unwrap();
1457 let test = "failed_ssh_docker_preflight_removes_durable_provisioning_record";
1458 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1459 command
1460 .args([
1461 "--exact",
1462 &format!("hel_controller::provisioning::tests::{test}"),
1463 "--nocapture",
1464 ])
1465 .env(SSH_DOCKER_FAILURE_CHILD, "1")
1466 .env("MJ_DATA_DIR", directory.path())
1467 .env("MJ_CONFIG_DIR", directory.path());
1468 let output = hel::hel_subprocess::run_with_input(&mut command, &[]).unwrap();
1469 assert!(
1470 output.status.success(),
1471 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1472 String::from_utf8_lossy(&output.stdout),
1473 String::from_utf8_lossy(&output.stderr)
1474 );
1475 return;
1476 }
1477
1478 let _writer = hel::hel_database::install_isolated_test_writer();
1479 let config = ssh_docker_registration_config();
1480 config.save().unwrap();
1481 let mut controller = Controller {
1482 config,
1483 state: HelState::default(),
1484 };
1485 let session_id = controller
1486 .register_session_with_resources(
1487 "codex",
1488 "project",
1489 "docker",
1490 "failed image",
1491 SessionLaunchOptions {
1492 initial_prompt: None,
1493 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1494 additional_mounts: Vec::new(),
1495 allow_dirty_local: false,
1496 resource_allocation: None,
1497 project_directory: None,
1498 session_title_override: None,
1499 },
1500 )
1501 .unwrap();
1502 assert!(
1503 hel::hel_database::load_state()
1504 .unwrap()
1505 .sessions
1506 .contains_key(&session_id)
1507 );
1508
1509 let executor = RecordingExecutor::failing("check Docker daemon");
1510 let error =
1511 futures::executor::block_on(controller.provision_session_with_failure_disposition(
1512 &session_id,
1513 &executor,
1514 None,
1515 ProvisioningFailureDisposition::Discard,
1516 ))
1517 .unwrap_err();
1518 let reported = format!("{error:#}");
1519 assert!(
1520 reported.contains("remote Docker preflight failed"),
1521 "{reported}"
1522 );
1523 assert!(
1524 executor.commands().iter().any(|argv| {
1525 let command = argv.join(" ");
1526 command.contains("'docker' 'version'")
1527 }),
1528 "the fake preflight did not run: {:?}",
1529 executor.commands()
1530 );
1531 assert!(!controller.state.sessions.contains_key(&session_id));
1532
1533 let reloaded = Controller::load().unwrap();
1534 assert!(
1535 !reloaded.state.sessions.contains_key(&session_id),
1536 "failed SSH Docker launch left a durable provisioning row"
1537 );
1538 }
1539
1540 #[test]
1541 fn failed_new_worker_start_discards_session_only_after_target_cleanup() {
1542 let session_id = "0123456789abcdef0123456789abcdef";
1543 let mut session = SessionRecord {
1544 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1545 archived: false,
1546 container_cpus: None,
1547 container_memory: None,
1548 id: session_id.into(),
1549 title: "new session".into(),
1550 harness_kind: hel::hel_config::HarnessKind::Kimi,
1551 last_profile: "kimi".into(),
1552 bundle_id: "raw-project".into(),
1553 project_directory: Some("/srv/project".into()),
1554 managed_worktree: None,
1555 target_template_id: "remote".into(),
1556 resource_allocation: None,
1557 additional_mounts: Vec::new(),
1558 state: SessionState::Disconnected,
1559 target: Some(TargetLocator::SshBare {
1560 host: "builder".into(),
1561 workspace: format!(".local/share/hel/workspaces/{session_id}").into(),
1562 worker_id: None,
1563 }),
1564 native_session_id: None,
1565 acp_session_title: None,
1566 session_title_override: None,
1567 created_at: "2026-08-12T00:00:00Z".into(),
1568 updated_at: "2026-08-12T00:00:00Z".into(),
1569 viewed_through_event_ordinal: 0,
1570 draft_input: String::new(),
1571 last_error: None,
1572 last_checkpoint_error: None,
1573 checkpoint: None,
1574 };
1575 let mut cleaned = HelState::default();
1576 cleaned.sessions.insert(session_id.into(), session.clone());
1577
1578 let failure =
1579 apply_failed_new_session_rollback(&mut cleaned, session_id, "ACP startup failed", None);
1580
1581 assert!(!cleaned.sessions.contains_key(session_id));
1582 assert!(
1583 failure
1584 .to_string()
1585 .contains("provisional session discarded")
1586 );
1587
1588 session.state = SessionState::Disconnected;
1589 let mut cleanup_failed = HelState::default();
1590 cleanup_failed.sessions.insert(session_id.into(), session);
1591 let failure = apply_failed_new_session_rollback(
1592 &mut cleanup_failed,
1593 session_id,
1594 "ACP startup failed",
1595 Some("ssh unavailable".into()),
1596 );
1597 let retained = cleanup_failed.sessions.get(session_id).unwrap();
1598 assert_eq!(retained.state, SessionState::Error);
1599 assert!(retained.target.is_some());
1600 assert!(failure.to_string().contains("cleanup"));
1601 }
1602 #[test]
1603 fn launch_failure_is_persisted_separately_from_session_state() {
1604 let directory = tempfile::tempdir().unwrap();
1605 let session_id = "0123456789abcdef0123456789abcdef";
1606 let detail = format!(
1607 "specific startup cause\n{}\nstderr tail survives",
1608 "x".repeat(MAX_LAUNCH_DIAGNOSTIC_BYTES)
1609 );
1610
1611 let path = persist_launch_failure_to(directory.path(), session_id, &detail).unwrap();
1612 let saved = std::fs::read_to_string(path).unwrap();
1613
1614 assert!(saved.contains("specific startup cause"));
1615 assert!(saved.contains("launch diagnostic truncated"));
1616 assert!(saved.contains("stderr tail survives"));
1617 #[cfg(unix)]
1618 {
1619 use std::os::unix::fs::PermissionsExt;
1620 assert_eq!(
1621 std::fs::metadata(directory.path())
1622 .unwrap()
1623 .permissions()
1624 .mode()
1625 & 0o777,
1626 0o700
1627 );
1628 }
1629 }
1630 #[test]
1631 fn inherited_git_settings_allow_only_portable_non_executable_values() {
1632 let settings = parse_inherited_git_settings(
1633 b"user.name\nAgent User\0USER.EMAIL\nagent@example.test\0pull.rebase\ntrue\0alias.deploy\n!ship\0credential.helper\nstore\0core.editor\nvim\0include.path\n/host/config\0user.name\nFinal User\0",
1634 )
1635 .unwrap();
1636
1637 assert_eq!(
1638 settings,
1639 BTreeMap::from([
1640 ("pull.rebase".into(), "true".into()),
1641 ("user.email".into(), "agent@example.test".into()),
1642 ("user.name".into(), "Final User".into()),
1643 ])
1644 );
1645 }
1646 #[test]
1647 fn inherited_git_settings_reject_malformed_or_non_utf8_output() {
1648 assert!(parse_inherited_git_settings(b"user.name\0").is_err());
1649 assert!(parse_inherited_git_settings(b"user.name\n\xff\0").is_err());
1650 }
1651 #[test]
1652 fn inherited_git_settings_target_only_isolated_workers() {
1653 let ssh = SshTarget {
1654 destination: "worker@example.test".into(),
1655 ssh_args: vec!["-p".into(), "2222".into()],
1656 };
1657 let ephemeral = [
1658 hel_targets::TargetLocator::LocalPodman {
1659 container_id: "abcdef012345".into(),
1660 workspace_storage: Default::default(),
1661 },
1662 hel_targets::TargetLocator::AppleContainer {
1663 container_id: "abcdef012346".into(),
1664 },
1665 hel_targets::TargetLocator::AwsEc2 {
1666 profile: "default".into(),
1667 region: "us-east-1".into(),
1668 instance_id: "i-1234567890abcdef0".into(),
1669 ssh: ssh.clone(),
1670 workspace: ".local/share/hel/workspaces/018f9dd2-a3b4-7c8d-9000-123456789abc"
1671 .into(),
1672 },
1673 hel_targets::TargetLocator::SshPodman {
1674 ssh: ssh.clone(),
1675 container_id: "abcdef012347".into(),
1676 workspace_storage: Default::default(),
1677 },
1678 ];
1679 for locator in &ephemeral {
1680 assert!(inherits_controller_git_settings(locator));
1681 let commands = inherited_git_setting_commands(
1682 locator,
1683 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1684 BTreeMap::from([("user.name".into(), "- Agent O'Brien 日本語".into())]),
1685 )
1686 .unwrap();
1687 assert_eq!(commands.len(), 1);
1688 assert!(
1689 commands[0]
1690 .args
1691 .iter()
1692 .any(|argument| argument.contains("user.name"))
1693 );
1694 assert!(
1695 commands[0]
1696 .args
1697 .iter()
1698 .any(|argument| argument.contains("- Agent O'"))
1699 );
1700 }
1701
1702 let persistent = hel_targets::TargetLocator::SshBare {
1703 ssh,
1704 workspace: "/srv/hel/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1705 };
1706 let local = hel_targets::TargetLocator::LocalBare {
1707 worker_root: "/var/lib/hel/workers/018f9dd2-a3b4-7c8d-9000-123456789abc".into(),
1708 };
1709 assert!(!inherits_controller_git_settings(&persistent));
1710 assert!(!inherits_controller_git_settings(&local));
1711 assert!(
1712 inherited_git_setting_commands(
1713 &persistent,
1714 "018f9dd2-a3b4-7c8d-9000-123456789abc",
1715 BTreeMap::from([("user.name".into(), "Agent".into())]),
1716 )
1717 .unwrap()
1718 .is_empty()
1719 );
1720 }
1721
1722 #[test]
1723 fn raw_ssh_targets_select_permissions_and_ssh_podman_is_unconstrained() {
1724 let ssh = hel::hel_config::SshConnection {
1725 host: "builder".into(),
1726 user: None,
1727 identity_file: None,
1728 extra_args: Vec::new(),
1729 };
1730 let guardian = TargetTemplate::SshBare {
1731 ssh: ssh.clone(),
1732 permissions: hel::hel_config::PermissionMode::Guardian,
1733 workspace_prefix: ".local/share/hel/workspaces".into(),
1734 };
1735 let podman = TargetTemplate::SshPodman {
1736 ssh: ssh.clone(),
1737 container: hel::hel_config::ContainerTemplate {
1738 image: "example.invalid/agent:latest".into(),
1739 pull_policy: Default::default(),
1740 platform: None,
1741 cpus: None,
1742 memory: None,
1743 environment: BTreeMap::new(),
1744 workspace_storage: Default::default(),
1745 },
1746 };
1747 let yolo = TargetTemplate::SshBare {
1748 ssh,
1749 permissions: hel::hel_config::PermissionMode::Yolo,
1750 workspace_prefix: ".local/share/hel/workspaces".into(),
1751 };
1752
1753 assert_eq!(
1754 TargetTemplate::LocalBare.execution_policy(),
1755 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
1756 );
1757 assert_eq!(
1758 guardian.execution_policy(),
1759 hel::hel_config::ExecutionPolicy::ConfiguredApprovals
1760 );
1761 assert_eq!(
1762 podman.execution_policy(),
1763 hel::hel_config::ExecutionPolicy::Unconstrained
1764 );
1765 assert_eq!(
1766 yolo.execution_policy(),
1767 hel::hel_config::ExecutionPolicy::Unconstrained
1768 );
1769 }
1770 const PROVISIONED_SESSION: &str = "0123456789abcdef0123456789abcdef";
1771
1772 struct RecordingExecutor {
1775 failing_purpose: String,
1776 commands: Mutex<Vec<Vec<String>>>,
1777 }
1778
1779 impl RecordingExecutor {
1780 fn failing(purpose: impl Into<String>) -> Self {
1781 Self {
1782 failing_purpose: purpose.into(),
1783 commands: Mutex::new(Vec::new()),
1784 }
1785 }
1786
1787 fn succeeding() -> Self {
1788 Self::failing(String::new())
1789 }
1790
1791 fn commands(&self) -> Vec<Vec<String>> {
1792 self.commands.lock().unwrap().clone()
1793 }
1794 }
1795
1796 impl CommandExecutor for RecordingExecutor {
1797 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1798 let mut argv = vec![command.program.clone()];
1799 argv.extend(command.args.clone());
1800 self.commands.lock().unwrap().push(argv);
1801 Ok(CommandOutput {
1802 status: i32::from(command.purpose == self.failing_purpose),
1803 stdout: Vec::new(),
1804 stderr: b"the step failed".to_vec(),
1805 })
1806 }
1807 }
1808
1809 fn container_targets() -> Vec<hel_targets::TargetTemplate> {
1810 let container = ContainerTemplate {
1811 image: "ubuntu:24.04".into(),
1812 pull_policy: Default::default(),
1813 extra_run_args: Vec::new(),
1814 workspace_storage: Default::default(),
1815 };
1816 vec![
1817 hel_targets::TargetTemplate::LocalPodman(container.clone()),
1818 hel_targets::TargetTemplate::AppleContainer(container.clone()),
1819 hel_targets::TargetTemplate::SshPodman {
1820 ssh: SshTarget {
1821 destination: "dev@example.test".into(),
1822 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
1823 },
1824 container,
1825 },
1826 ]
1827 }
1828
1829 #[test]
1830 fn a_failure_after_the_container_exists_removes_it_and_keeps_the_original_error() {
1831 let name = hel_targets::resource_name(PROVISIONED_SESSION).unwrap();
1832 for target in container_targets() {
1833 let plan =
1834 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
1835 .unwrap();
1836 let executor = RecordingExecutor::failing("clone app");
1837
1838 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1839 unreachable!("locator discovery must not run after a failed plan")
1840 })
1841 .unwrap_err();
1842
1843 let reported = format!("{error:#}");
1844 assert!(reported.contains("clone app failed"), "{reported}");
1845 assert!(reported.contains("cleanup succeeded"), "{reported}");
1846 let removal = executor
1848 .commands()
1849 .into_iter()
1850 .map(|arguments| arguments.join(" ").replace('\'', ""))
1851 .find(|command| command.contains("rm --force") && command.contains(&name))
1852 .expect("cleanup removes the exact provisioned container");
1853 assert!(removal.contains("rm --force"), "{removal}");
1854 assert!(removal.contains(&name), "{removal}");
1855 }
1856 }
1857
1858 #[test]
1859 fn target_creation_returns_repository_setup_without_running_it() {
1860 let target = podman_target();
1861 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
1862 .unwrap();
1863 let executor = RecordingExecutor::succeeding();
1864
1865 let (_, repositories) =
1866 provision_target_creation(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1867 Ok(TargetLocator::LocalPodman {
1868 container_id: hel_targets::resource_name(PROVISIONED_SESSION)?,
1869 workspace_storage: Default::default(),
1870 })
1871 })
1872 .unwrap();
1873
1874 assert_eq!(executor.commands().len(), 1, "only podman run may execute");
1875 assert!(
1876 repositories
1877 .commands
1878 .iter()
1879 .any(|command| command.purpose == "clone app")
1880 );
1881 }
1882
1883 #[test]
1884 fn a_target_whose_creation_failed_is_never_torn_down() {
1885 for target in container_targets() {
1886 let plan =
1887 hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
1888 .unwrap();
1889 let creation = plan.split_at_target_creation().unwrap().0;
1890 let executor =
1891 RecordingExecutor::failing(creation.commands.last().unwrap().purpose.clone());
1892
1893 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1894 unreachable!("locator discovery must not run after a failed plan")
1895 })
1896 .unwrap_err();
1897
1898 let reported = format!("{error:#}");
1899 assert!(!reported.contains("cleanup"), "{reported}");
1900 assert!(
1901 !executor
1902 .commands()
1903 .iter()
1904 .any(|argv| argv.join(" ").contains("rm --force")),
1905 "{:?}",
1906 executor.commands()
1907 );
1908 }
1909 }
1910
1911 #[test]
1912 fn a_target_whose_locator_cannot_be_discovered_is_removed_again() {
1913 let target = podman_target();
1914 let plan = hel_targets::provision_plan(&target, PROVISIONED_SESSION, &probe_bundle(), &[])
1915 .unwrap();
1916 let executor = RecordingExecutor::succeeding();
1917
1918 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1919 bail!("the container never reported an address")
1920 })
1921 .unwrap_err();
1922
1923 let reported = format!("{error:#}");
1924 assert!(reported.contains("never reported an address"), "{reported}");
1925 assert!(reported.contains("cleanup succeeded"), "{reported}");
1926 let removal = executor
1927 .commands()
1928 .into_iter()
1929 .map(|arguments| arguments.join(" "))
1930 .find(|command| command.contains("podman rm --force --ignore"))
1931 .expect("cleanup removes the provisioned Podman container");
1932 assert!(removal.contains("podman rm --force --ignore"), "{removal}");
1933 }
1934
1935 #[test]
1938 fn a_bare_project_failure_removes_nothing() {
1939 let target = hel_targets::TargetTemplate::LocalBare;
1940 let plan =
1941 hel_targets::provision_bare_project_plan(&target, PROVISIONED_SESSION, "/srv/project")
1942 .unwrap();
1943 let executor = RecordingExecutor::succeeding();
1944
1945 let error = provision_target(&plan, &target, PROVISIONED_SESSION, &executor, |_| {
1946 bail!("the worker root was unreadable")
1947 })
1948 .unwrap_err();
1949
1950 assert!(!format!("{error:#}").contains("cleanup"));
1951 assert!(executor.commands().is_empty());
1952 }
1953}