Skip to main content

aft/bash_background/
mod.rs

1//! Background bash task management: spawning detached tasks, the watchdog that
2//! reaps them, output buffering/compression, and on-disk persistence so tasks
3//! survive a bridge restart.
4
5pub mod buffer;
6pub mod output;
7pub mod persistence;
8pub mod process;
9pub mod pty_process;
10pub mod pty_runtime;
11pub mod registry;
12pub mod watchdog;
13pub mod watches;
14
15use crate::bash_permissions::PermissionAsk;
16use crate::context::AppContext;
17use crate::protocol::Response;
18#[cfg(unix)]
19use crate::sandbox_spawn::native_sandbox_enforced;
20use crate::sandbox_spawn::{
21    current_authenticated_principal, resolve_sandbox_spawn, HostEscalationAttempt,
22    RequestedSandboxTier, SandboxTaskKind,
23};
24use persistence::BgMode;
25use serde::{Deserialize, Serialize};
26use serde_json::json;
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::time::Duration;
30
31pub use registry::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry, WatchdogPassCause};
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum BashShell {
36    #[default]
37    Bash,
38    Powershell,
39}
40
41impl BashShell {
42    pub(crate) fn is_powershell(self) -> bool {
43        matches!(self, Self::Powershell)
44    }
45
46    pub(crate) fn command_text(self, command: &str) -> String {
47        if self.is_powershell() {
48            // Match Pi's optional tool: both .NET and PowerShell's pipeline use
49            // UTF-8 before user code runs, so redirected native output remains
50            // readable across macOS, Linux, and Windows.
51            format!(
52                "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding;\n{command}"
53            )
54        } else {
55            command.to_string()
56        }
57    }
58}
59
60fn resolve_powershell_path_with(
61    lookup: impl FnOnce(&str) -> Option<PathBuf>,
62) -> Result<PathBuf, String> {
63    #[cfg(windows)]
64    let candidate = "pwsh.exe";
65    #[cfg(not(windows))]
66    let candidate = "pwsh";
67    lookup(candidate).ok_or_else(|| {
68        "PowerShell (pwsh) is not installed or is not on PATH. Install PowerShell 7+: https://aka.ms/powershell"
69            .to_string()
70    })
71}
72
73pub(crate) fn resolve_shell_path(pty: bool, shell: BashShell) -> Result<PathBuf, String> {
74    if shell.is_powershell() {
75        return resolve_powershell_path_with(|candidate| which::which(candidate).ok());
76    }
77
78    #[cfg(unix)]
79    {
80        Ok(if pty {
81            pty_process::resolve_posix_shell()
82        } else {
83            registry::resolve_posix_shell()
84        })
85    }
86    #[cfg(windows)]
87    {
88        let _ = pty;
89        Ok(PathBuf::from("cmd.exe"))
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct BgTaskInfo {
95    pub task_id: String,
96    pub status: BgTaskStatus,
97    pub command: String,
98    pub mode: BgMode,
99    pub started_at: u64,
100    pub duration_ms: Option<u64>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub status_reason: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum BgTaskStatus {
108    Starting,
109    Running,
110    Killing,
111    Completed,
112    Failed,
113    Killed,
114    TimedOut,
115    FateUnknown,
116}
117
118impl BgTaskStatus {
119    pub fn is_terminal(&self) -> bool {
120        matches!(
121            self,
122            BgTaskStatus::Completed
123                | BgTaskStatus::Failed
124                | BgTaskStatus::Killed
125                | BgTaskStatus::TimedOut
126                | BgTaskStatus::FateUnknown
127        )
128    }
129}
130
131/// Spawn a bash command in the background. Returns a task_id immediately.
132#[allow(clippy::too_many_arguments)]
133pub fn spawn(
134    request_id: &str,
135    session_id: &str,
136    command: &str,
137    shell: BashShell,
138    shell_path: PathBuf,
139    workdir: Option<PathBuf>,
140    env: Option<HashMap<String, String>>,
141    timeout_ms: Option<u64>,
142    ctx: &AppContext,
143    require_background_flag: bool,
144    notify_on_completion: bool,
145    compressed: bool,
146    pty: bool,
147    pty_rows: u16,
148    pty_cols: u16,
149    scanner_report: Vec<PermissionAsk>,
150    host_escalation: Option<HostEscalationAttempt>,
151) -> Response {
152    if require_background_flag && !ctx.config().experimental_bash_background {
153        return Response::error(
154            request_id,
155            "feature_disabled",
156            "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
157        );
158    }
159
160    let workdir = workdir.unwrap_or_else(|| {
161        ctx.config().project_root.clone().unwrap_or_else(|| {
162            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
163        })
164    });
165    let storage_dir = task_storage_dir(ctx);
166    let max_running = ctx.config().max_background_bash_tasks;
167    let timeout = timeout_ms.map(Duration::from_millis);
168    let project_root = ctx
169        .config()
170        .project_root
171        .clone()
172        .or_else(|| std::env::current_dir().ok())
173        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
174
175    let mut env = env.unwrap_or_default();
176    let config = ctx.config();
177    let child_storage_root = self::storage_dir(config.storage_dir.as_deref());
178    if let Err(error) =
179        crate::agent_child_env::inject(config.as_ref(), &child_storage_root, &mut env)
180    {
181        return Response::error(request_id, "child_environment_unavailable", error);
182    }
183    #[cfg(target_os = "linux")]
184    if !pty && config.bash.linux_scope {
185        env.insert(registry::LINUX_SCOPE_ENV.to_string(), "1".to_string());
186    }
187    let task_kind = if pty {
188        SandboxTaskKind::BashPty
189    } else if require_background_flag {
190        SandboxTaskKind::BashBackground
191    } else {
192        SandboxTaskKind::BashForeground
193    };
194    let principal = current_authenticated_principal();
195    let requested_tier = if host_escalation.is_some() {
196        RequestedSandboxTier::Host
197    } else if ctx.config().sandbox.enabled {
198        RequestedSandboxTier::Native
199    } else {
200        RequestedSandboxTier::Disabled
201    };
202    let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
203    #[cfg(unix)]
204    let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
205        && host_escalation.is_none()
206    {
207        let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
208            Ok(task) => task,
209            Err(error) => {
210                return Response::error(
211                    request_id,
212                    "sandbox_unavailable",
213                    format!(
214                        "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
215                    ),
216                );
217            }
218        };
219        let plan = resolve_sandbox_spawn(
220            ctx,
221            &principal,
222            requested_tier,
223            task_kind,
224            &task.paths.io_dir,
225            None,
226        );
227        if plan.refusal_code().is_some() {
228            (plan, Some(task))
229        } else {
230            let root = project_root.as_deref().unwrap_or(&workdir);
231            let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
232            match crate::sandbox_spawn::prepare_task_payload(
233                &task,
234                command.as_bytes(),
235                root,
236                &workdir,
237                &principal,
238                &shell_path,
239                &environment,
240            ) {
241                Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
242                Err(error) => {
243                    let _ = persistence::delete_resolved_task(&task);
244                    return Response::error(
245                        request_id,
246                        "sandbox_unavailable",
247                        format!("native sandbox failed to materialize task payload: {error}"),
248                    );
249                }
250            }
251        }
252    } else {
253        (
254            resolve_sandbox_spawn(
255                ctx,
256                &principal,
257                requested_tier,
258                task_kind,
259                &session_dir,
260                host_escalation.as_ref(),
261            ),
262            None,
263        )
264    };
265    #[cfg(not(unix))]
266    let spawn_plan = resolve_sandbox_spawn(
267        ctx,
268        &principal,
269        requested_tier,
270        task_kind,
271        &session_dir,
272        host_escalation.as_ref(),
273    );
274    if let Some(code) = spawn_plan.refusal_code() {
275        #[cfg(unix)]
276        if let Some(task) = unregistered_task.as_ref() {
277            let _ = persistence::delete_resolved_task(task);
278        }
279        let message = spawn_plan
280            .refusal_message()
281            .unwrap_or("bash process creation refused by sandbox policy");
282        return match spawn_plan.refusal_mismatch_class() {
283            Some(class) => Response::error_with_data(
284                request_id,
285                code,
286                message,
287                json!({ "mismatch_class": class }),
288            ),
289            None => Response::error(request_id, code, message),
290        };
291    }
292
293    let cleanup_plan = spawn_plan.clone();
294    let spawn_result = if pty {
295        ctx.bash_background().spawn_pty_with_shell(
296            spawn_plan,
297            command,
298            shell,
299            shell_path,
300            session_id.to_string(),
301            workdir,
302            env,
303            timeout,
304            storage_dir,
305            max_running,
306            notify_on_completion,
307            compressed,
308            project_root,
309            pty_rows,
310            pty_cols,
311        )
312    } else {
313        ctx.bash_background().spawn_with_shell(
314            spawn_plan,
315            command,
316            shell,
317            shell_path,
318            session_id.to_string(),
319            workdir,
320            env,
321            timeout,
322            storage_dir,
323            max_running,
324            notify_on_completion,
325            compressed,
326            project_root,
327        )
328    };
329
330    match spawn_result {
331        Ok(task_id) => {
332            if let Err(error) =
333                ctx.bash_background()
334                    .record_scanner_report(&task_id, session_id, scanner_report)
335            {
336                crate::slog_warn!("{error}");
337            }
338            Response::success(
339                request_id,
340                json!({
341                    "task_id": task_id,
342                    "status": BgTaskStatus::Running,
343                    "mode": if pty { "pty" } else { "pipes" },
344                }),
345            )
346        }
347        Err(message) if message.contains("limit exceeded") => {
348            cleanup_plan.cleanup_unspawned();
349            #[cfg(unix)]
350            if let Some(task) = unregistered_task.as_ref() {
351                let _ = persistence::delete_resolved_task(task);
352            }
353            Response::error(request_id, "background_task_limit_exceeded", message)
354        }
355        Err(message) => {
356            cleanup_plan.cleanup_unspawned();
357            #[cfg(unix)]
358            if let Some(task) = unregistered_task.as_ref() {
359                let _ = persistence::delete_resolved_task(task);
360            }
361            if cleanup_plan.is_native_launcher() {
362                Response::error(
363                    request_id,
364                    "sandbox_unavailable",
365                    format!(
366                        "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
367                    ),
368                )
369            } else {
370                Response::error(request_id, "execution_failed", message)
371            }
372        }
373    }
374}
375
376pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
377    let config = ctx.config();
378    let root = storage_dir(config.storage_dir.as_deref());
379    config
380        .harness
381        .as_ref()
382        .map(|harness| root.join(harness.storage_segment()))
383        .unwrap_or(root)
384}
385
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387enum StoragePlatform {
388    Windows,
389    Other,
390}
391
392impl StoragePlatform {
393    fn current() -> Self {
394        if cfg!(windows) {
395            Self::Windows
396        } else {
397            Self::Other
398        }
399    }
400}
401
402/// Resolve the process-state storage root exactly once for every Rust entry point.
403/// The environment override is checked here so it wins over a stale plugin wire
404/// value, while both plugin-less fallback and plugin-injected paths share one root.
405///
406/// Last re-derived 2026-09-06 against subconscious d5e09914b0791a66f2a5a00a9bb3422860ade95e:
407/// compare the ordered variables, platform gates, and empty-value guards below with
408/// `subc-core/src/daemon_config.rs::default_data_home`, resolve its named constants,
409/// then preserve the documented Windows cache-class divergence.
410pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
411    let lookup = |name: &str| std::env::var_os(name);
412    let fallback_home = std::env::home_dir();
413    let current_dir = std::env::current_dir().ok();
414    storage_dir_from(
415        configured,
416        &lookup,
417        StoragePlatform::current(),
418        fallback_home.as_deref(),
419        current_dir.as_deref(),
420    )
421}
422
423fn storage_dir_from(
424    configured: Option<&std::path::Path>,
425    lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
426    platform: StoragePlatform,
427    fallback_home: Option<&std::path::Path>,
428    current_dir: Option<&std::path::Path>,
429) -> PathBuf {
430    let resolve = |path: &std::path::Path| {
431        resolve_storage_path_from(path, lookup, platform, fallback_home, current_dir)
432    };
433
434    if let Some(dir) = non_empty_env_path_from(lookup, "AFT_STORAGE_DIR") {
435        return resolve(&dir);
436    }
437    if let Some(dir) = configured.filter(|path| !path.as_os_str().is_empty()) {
438        // Explicit process-state paths are already caller-owned. Preserve their
439        // spelling so every downstream read/write uses the exact configured root.
440        return dir.to_path_buf();
441    }
442    // AFT_CACHE_DIR predates AFT_STORAGE_DIR as the storage sandbox lever. It
443    // remains above the shared data-home ladder so old isolated invocations do
444    // not escape into the operator's data root.
445    if let Some(dir) = non_empty_env_path_from(lookup, "AFT_CACHE_DIR") {
446        return resolve(&dir).join("aft");
447    }
448
449    resolve(&cortexkit_data_root_from(lookup, platform))
450        .join("cortexkit")
451        .join("aft")
452}
453
454fn non_empty_env_path_from(
455    lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
456    name: &str,
457) -> Option<PathBuf> {
458    lookup(name)
459        .filter(|value| !value.is_empty())
460        .map(PathBuf::from)
461}
462
463fn storage_home_dir_from(
464    lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
465    platform: StoragePlatform,
466    fallback_home: Option<&std::path::Path>,
467) -> Option<PathBuf> {
468    let configured = if platform == StoragePlatform::Windows {
469        non_empty_env_path_from(lookup, "USERPROFILE")
470            .or_else(|| non_empty_env_path_from(lookup, "HOME"))
471    } else {
472        non_empty_env_path_from(lookup, "HOME")
473            .or_else(|| non_empty_env_path_from(lookup, "USERPROFILE"))
474    };
475    configured.or_else(|| fallback_home.map(PathBuf::from))
476}
477
478fn cortexkit_data_root_from(
479    lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
480    platform: StoragePlatform,
481) -> PathBuf {
482    if let Some(dir) = non_empty_env_path_from(lookup, "XDG_DATA_HOME") {
483        return dir;
484    }
485    if platform == StoragePlatform::Windows {
486        // AFT stores indexes, backups, and checkpoints here.
487        // cache-class storage; stable for existing installs.
488        // Do not move this shipped ladder to Roaming.
489        if let Some(dir) = non_empty_env_path_from(lookup, "LOCALAPPDATA") {
490            return dir;
491        }
492        if let Some(home) = non_empty_env_path_from(lookup, "USERPROFILE") {
493            return home.join("AppData").join("Local");
494        }
495    }
496    if let Some(home) = non_empty_env_path_from(lookup, "HOME") {
497        return home.join(".local").join("share");
498    }
499    PathBuf::from(".local").join("share")
500}
501
502fn resolve_storage_path_from(
503    path: &std::path::Path,
504    lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
505    platform: StoragePlatform,
506    fallback_home: Option<&std::path::Path>,
507    current_dir: Option<&std::path::Path>,
508) -> PathBuf {
509    let storage_home = || storage_home_dir_from(lookup, platform, fallback_home);
510    let expanded = if path == std::path::Path::new("~") {
511        storage_home().unwrap_or_else(|| path.to_path_buf())
512    } else if let Some(raw) = path.to_str() {
513        if raw.starts_with("~/") || raw.starts_with("~\\") {
514            storage_home()
515                .map(|home| home.join(&raw[2..]))
516                .unwrap_or_else(|| path.to_path_buf())
517        } else {
518            path.to_path_buf()
519        }
520    } else {
521        path.to_path_buf()
522    };
523    let absolute = if expanded.is_absolute() {
524        expanded
525    } else if let Some(current_dir) = current_dir {
526        current_dir.join(expanded)
527    } else {
528        expanded
529    };
530    normalize_absolute_path(&absolute)
531}
532
533fn normalize_absolute_path(path: &std::path::Path) -> PathBuf {
534    use std::path::Component;
535
536    let mut normalized = PathBuf::new();
537    for component in path.components() {
538        match component {
539            Component::CurDir => {}
540            Component::ParentDir => {
541                if !normalized.pop() {
542                    normalized.push(component.as_os_str());
543                }
544            }
545            other => normalized.push(other.as_os_str()),
546        }
547    }
548    normalized
549}
550
551pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
552    let root_tasks = storage_root.join("bash-tasks");
553    if !dir_has_entries(&root_tasks) {
554        return;
555    }
556
557    let harness_tasks = storage_root
558        .join(harness.storage_segment())
559        .join("bash-tasks");
560    if dir_has_entries(&harness_tasks) {
561        return;
562    }
563    if let Some(parent) = harness_tasks.parent() {
564        if let Err(error) = std::fs::create_dir_all(parent) {
565            crate::slog_warn!(
566                "failed to create harness bash task dir {}: {}",
567                parent.display(),
568                error
569            );
570            return;
571        }
572    }
573    if harness_tasks.exists() {
574        let _ = std::fs::remove_dir(&harness_tasks);
575    }
576
577    match std::fs::rename(&root_tasks, &harness_tasks) {
578        Ok(()) => crate::slog_info!(
579            "moved legacy root bash tasks into harness namespace: {}",
580            harness_tasks.display()
581        ),
582        Err(error) => {
583            crate::slog_warn!(
584                "failed to move legacy root bash tasks into {}: {}; trying child merge",
585                harness_tasks.display(),
586                error
587            );
588            if std::fs::create_dir_all(&harness_tasks).is_err() {
589                return;
590            }
591            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
592                for entry in entries.flatten() {
593                    let source = entry.path();
594                    let target = harness_tasks.join(entry.file_name());
595                    if !target.exists() {
596                        let _ = std::fs::rename(source, target);
597                    }
598                }
599            }
600            let _ = std::fs::remove_dir(&root_tasks);
601        }
602    }
603}
604
605fn dir_has_entries(path: &std::path::Path) -> bool {
606    std::fs::read_dir(path)
607        .map(|mut entries| entries.next().is_some())
608        .unwrap_or(false)
609}
610
611#[cfg(test)]
612mod storage_root_tests {
613    use std::collections::HashMap;
614    use std::ffi::OsString;
615    use std::panic::{catch_unwind, AssertUnwindSafe};
616    use std::path::{Path, PathBuf};
617
618    struct NonPanickingCleanup<F: FnOnce()> {
619        cleanup: Option<F>,
620    }
621
622    impl<F: FnOnce()> NonPanickingCleanup<F> {
623        fn new(cleanup: F) -> Self {
624            Self {
625                cleanup: Some(cleanup),
626            }
627        }
628    }
629
630    impl<F: FnOnce()> Drop for NonPanickingCleanup<F> {
631        fn drop(&mut self) {
632            let Some(cleanup) = self.cleanup.take() else {
633                return;
634            };
635            // A cleanup panic while the test is already unwinding aborts the whole
636            // libtest process, so cleanup failures must remain contained here.
637            let _ = catch_unwind(AssertUnwindSafe(cleanup));
638        }
639    }
640
641    fn resolve_storage_fixture(
642        env: &HashMap<&str, OsString>,
643        configured: Option<&Path>,
644        platform: super::StoragePlatform,
645        fallback_home: Option<&Path>,
646        current_dir: Option<&Path>,
647    ) -> PathBuf {
648        super::storage_dir_from(
649            configured,
650            &|name| env.get(name).cloned(),
651            platform,
652            fallback_home,
653            current_dir,
654        )
655    }
656
657    #[test]
658    fn storage_ladder_matches_daemon_except_for_stable_windows_cache_class_storage() {
659        let current_dir = Path::new("/work");
660        let fallback_home = Path::new("/system-home");
661        let module_suffix = Path::new("cortexkit").join("aft");
662        let mut env = HashMap::from([
663            ("AFT_STORAGE_DIR", OsString::new()),
664            ("AFT_CACHE_DIR", OsString::new()),
665            ("XDG_DATA_HOME", OsString::new()),
666            ("APPDATA", OsString::from("/wrong-roaming-data")),
667            ("USERPROFILE", OsString::new()),
668            ("HOME", OsString::new()),
669            ("LOCALAPPDATA", OsString::new()),
670        ]);
671
672        for platform in [
673            super::StoragePlatform::Other,
674            super::StoragePlatform::Windows,
675        ] {
676            assert_eq!(
677                resolve_storage_fixture(
678                    &env,
679                    Some(Path::new("")),
680                    platform,
681                    Some(fallback_home),
682                    Some(current_dir),
683                ),
684                current_dir.join(".local/share").join(&module_suffix),
685                "empty values and an empty configured root are unset"
686            );
687        }
688        assert_eq!(
689            resolve_storage_fixture(&env, None, super::StoragePlatform::Other, None, None,),
690            PathBuf::from(".local/share/cortexkit/aft"),
691            "an unavailable cwd preserves the honest relative path"
692        );
693
694        env.insert("HOME", OsString::from("/home/operator"));
695        env.insert("USERPROFILE", OsString::from("/wrong-profile"));
696        assert_eq!(
697            resolve_storage_fixture(
698                &env,
699                None,
700                super::StoragePlatform::Other,
701                Some(fallback_home),
702                Some(current_dir),
703            ),
704            Path::new("/home/operator/.local/share").join(&module_suffix)
705        );
706
707        env.insert("LOCALAPPDATA", OsString::from("/local-data"));
708        assert_eq!(
709            resolve_storage_fixture(
710                &env,
711                None,
712                super::StoragePlatform::Windows,
713                Some(fallback_home),
714                Some(current_dir),
715            ),
716            Path::new("/local-data").join(&module_suffix)
717        );
718        env.insert("LOCALAPPDATA", OsString::new());
719        assert_eq!(
720            resolve_storage_fixture(
721                &env,
722                None,
723                super::StoragePlatform::Windows,
724                Some(fallback_home),
725                Some(current_dir),
726            ),
727            Path::new("/wrong-profile/AppData/Local").join(&module_suffix)
728        );
729
730        env.insert("XDG_DATA_HOME", OsString::from("relative-data"));
731        assert_eq!(
732            resolve_storage_fixture(
733                &env,
734                None,
735                super::StoragePlatform::Other,
736                Some(fallback_home),
737                Some(current_dir),
738            ),
739            current_dir.join("relative-data").join(&module_suffix)
740        );
741
742        env.insert("AFT_CACHE_DIR", OsString::from("/legacy-cache"));
743        assert_eq!(
744            resolve_storage_fixture(
745                &env,
746                None,
747                super::StoragePlatform::Other,
748                Some(fallback_home),
749                Some(current_dir),
750            ),
751            PathBuf::from("/legacy-cache/aft")
752        );
753        let configured = Path::new("configured/../configured-aft");
754        assert_eq!(
755            resolve_storage_fixture(
756                &env,
757                Some(configured),
758                super::StoragePlatform::Other,
759                Some(fallback_home),
760                Some(current_dir),
761            ),
762            configured,
763            "the caller-owned configured spelling outranks the legacy cache lever"
764        );
765
766        env.insert("AFT_STORAGE_DIR", OsString::from("~/operator-aft"));
767        assert_eq!(
768            resolve_storage_fixture(
769                &env,
770                Some(configured),
771                super::StoragePlatform::Other,
772                Some(fallback_home),
773                Some(current_dir),
774            ),
775            PathBuf::from("/home/operator/operator-aft")
776        );
777    }
778
779    #[test]
780    fn powershell_absence_has_an_honest_install_remedy() {
781        let error = super::resolve_powershell_path_with(|_| None).expect_err("pwsh is absent");
782        assert!(error.contains("PowerShell (pwsh) is not installed"));
783        assert!(error.contains("https://aka.ms/powershell"));
784    }
785
786    #[test]
787    fn cleanup_panic_during_unwind_does_not_abort_libtest() {
788        use std::sync::atomic::{AtomicBool, Ordering};
789
790        let cleanup_ran = AtomicBool::new(false);
791        let unwind = catch_unwind(AssertUnwindSafe(|| {
792            let _cleanup = NonPanickingCleanup::new(|| {
793                cleanup_ran.store(true, Ordering::SeqCst);
794                panic!("forced cleanup failure");
795            });
796            panic!("primary test failure");
797        }));
798
799        assert!(cleanup_ran.load(Ordering::SeqCst));
800        assert_eq!(
801            unwind
802                .expect_err("primary panic must escape the inner scope")
803                .downcast_ref::<&str>(),
804            Some(&"primary test failure")
805        );
806    }
807}