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};
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    let task_kind = if pty {
184        SandboxTaskKind::BashPty
185    } else if require_background_flag {
186        SandboxTaskKind::BashBackground
187    } else {
188        SandboxTaskKind::BashForeground
189    };
190    let principal = current_authenticated_principal();
191    let requested_tier = if host_escalation.is_some() {
192        RequestedSandboxTier::Host
193    } else if ctx.config().sandbox.enabled {
194        RequestedSandboxTier::Native
195    } else {
196        RequestedSandboxTier::Disabled
197    };
198    let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
199    #[cfg(unix)]
200    let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
201        && host_escalation.is_none()
202    {
203        let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
204            Ok(task) => task,
205            Err(error) => {
206                return Response::error(
207                    request_id,
208                    "sandbox_unavailable",
209                    format!(
210                        "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
211                    ),
212                );
213            }
214        };
215        let plan = resolve_sandbox_spawn(
216            ctx,
217            &principal,
218            requested_tier,
219            task_kind,
220            &task.paths.io_dir,
221            None,
222        );
223        if plan.refusal_code().is_some() {
224            (plan, Some(task))
225        } else {
226            let root = project_root.as_deref().unwrap_or(&workdir);
227            let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
228            match crate::sandbox_spawn::prepare_task_payload(
229                &task,
230                command.as_bytes(),
231                root,
232                &workdir,
233                &principal,
234                &shell_path,
235                &environment,
236            ) {
237                Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
238                Err(error) => {
239                    let _ = persistence::delete_resolved_task(&task);
240                    return Response::error(
241                        request_id,
242                        "sandbox_unavailable",
243                        format!("native sandbox failed to materialize task payload: {error}"),
244                    );
245                }
246            }
247        }
248    } else {
249        (
250            resolve_sandbox_spawn(
251                ctx,
252                &principal,
253                requested_tier,
254                task_kind,
255                &session_dir,
256                host_escalation.as_ref(),
257            ),
258            None,
259        )
260    };
261    #[cfg(not(unix))]
262    let spawn_plan = resolve_sandbox_spawn(
263        ctx,
264        &principal,
265        requested_tier,
266        task_kind,
267        &session_dir,
268        host_escalation.as_ref(),
269    );
270    if let Some(code) = spawn_plan.refusal_code() {
271        #[cfg(unix)]
272        if let Some(task) = unregistered_task.as_ref() {
273            let _ = persistence::delete_resolved_task(task);
274        }
275        let message = spawn_plan
276            .refusal_message()
277            .unwrap_or("bash process creation refused by sandbox policy");
278        return match spawn_plan.refusal_mismatch_class() {
279            Some(class) => Response::error_with_data(
280                request_id,
281                code,
282                message,
283                json!({ "mismatch_class": class }),
284            ),
285            None => Response::error(request_id, code, message),
286        };
287    }
288
289    let cleanup_plan = spawn_plan.clone();
290    let spawn_result = if pty {
291        ctx.bash_background().spawn_pty_with_shell(
292            spawn_plan,
293            command,
294            shell,
295            shell_path,
296            session_id.to_string(),
297            workdir,
298            env,
299            timeout,
300            storage_dir,
301            max_running,
302            notify_on_completion,
303            compressed,
304            project_root,
305            pty_rows,
306            pty_cols,
307        )
308    } else {
309        ctx.bash_background().spawn_with_shell(
310            spawn_plan,
311            command,
312            shell,
313            shell_path,
314            session_id.to_string(),
315            workdir,
316            env,
317            timeout,
318            storage_dir,
319            max_running,
320            notify_on_completion,
321            compressed,
322            project_root,
323        )
324    };
325
326    match spawn_result {
327        Ok(task_id) => {
328            if let Err(error) =
329                ctx.bash_background()
330                    .record_scanner_report(&task_id, session_id, scanner_report)
331            {
332                crate::slog_warn!("{error}");
333            }
334            Response::success(
335                request_id,
336                json!({
337                    "task_id": task_id,
338                    "status": BgTaskStatus::Running,
339                    "mode": if pty { "pty" } else { "pipes" },
340                }),
341            )
342        }
343        Err(message) if message.contains("limit exceeded") => {
344            cleanup_plan.cleanup_unspawned();
345            #[cfg(unix)]
346            if let Some(task) = unregistered_task.as_ref() {
347                let _ = persistence::delete_resolved_task(task);
348            }
349            Response::error(request_id, "background_task_limit_exceeded", message)
350        }
351        Err(message) => {
352            cleanup_plan.cleanup_unspawned();
353            #[cfg(unix)]
354            if let Some(task) = unregistered_task.as_ref() {
355                let _ = persistence::delete_resolved_task(task);
356            }
357            if cleanup_plan.is_native_launcher() {
358                Response::error(
359                    request_id,
360                    "sandbox_unavailable",
361                    format!(
362                        "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
363                    ),
364                )
365            } else {
366                Response::error(request_id, "execution_failed", message)
367            }
368        }
369    }
370}
371
372pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
373    let config = ctx.config();
374    let root = storage_dir(config.storage_dir.as_deref());
375    config
376        .harness
377        .as_ref()
378        .map(|harness| root.join(harness.storage_segment()))
379        .unwrap_or(root)
380}
381
382/// Resolve the process-state storage root exactly once for every Rust entry point.
383/// The environment override is checked here so it wins over a stale plugin wire
384/// value, while both plugin-less fallback and plugin-injected paths share one root.
385pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
386    if let Some(dir) = non_empty_env_path("AFT_STORAGE_DIR") {
387        return resolve_storage_path(&dir);
388    }
389    if let Some(dir) = configured {
390        // Explicit process-state paths are already caller-owned. Preserve their
391        // spelling so every downstream read/write uses the exact configured root.
392        return dir.to_path_buf();
393    }
394    // AFT_CACHE_DIR outranks the computed data root: it predates
395    // AFT_STORAGE_DIR as the storage sandbox lever, and the data root is
396    // effectively always derivable (any HOME yields one), so ranking it
397    // higher would leave the cache-dir arm permanently dead and leak
398    // sandboxed fixtures onto the real machine root.
399    if let Some(dir) = non_empty_env_path("AFT_CACHE_DIR") {
400        return resolve_storage_path(&dir).join("aft");
401    }
402    if let Some(root) = cortexkit_data_root() {
403        return root.join("cortexkit").join("aft");
404    }
405    std::env::temp_dir().join("cortexkit").join("aft")
406}
407
408fn non_empty_env_path(name: &str) -> Option<PathBuf> {
409    std::env::var_os(name)
410        .filter(|value| !value.is_empty())
411        .map(PathBuf::from)
412}
413
414fn storage_home_dir() -> Option<PathBuf> {
415    let configured = if cfg!(windows) {
416        non_empty_env_path("USERPROFILE").or_else(|| non_empty_env_path("HOME"))
417    } else {
418        non_empty_env_path("HOME").or_else(|| non_empty_env_path("USERPROFILE"))
419    };
420    configured.or_else(std::env::home_dir)
421}
422
423fn cortexkit_data_root() -> Option<PathBuf> {
424    if let Some(dir) = non_empty_env_path("XDG_DATA_HOME") {
425        return Some(resolve_storage_path(&dir));
426    }
427    if cfg!(windows) {
428        if let Some(dir) = std::env::var_os("LOCALAPPDATA")
429            .filter(|value| !value.is_empty())
430            .or_else(|| std::env::var_os("APPDATA").filter(|value| !value.is_empty()))
431            .map(PathBuf::from)
432        {
433            return Some(resolve_storage_path(&dir));
434        }
435    }
436    storage_home_dir().map(|home| {
437        let root = if cfg!(windows) {
438            home.join("AppData").join("Local")
439        } else {
440            home.join(".local").join("share")
441        };
442        resolve_storage_path(&root)
443    })
444}
445
446fn resolve_storage_path(path: &std::path::Path) -> PathBuf {
447    let expanded = if path == std::path::Path::new("~") {
448        storage_home_dir().unwrap_or_else(std::env::temp_dir)
449    } else if let Some(raw) = path.to_str() {
450        if raw.starts_with("~/") || raw.starts_with("~\\") {
451            storage_home_dir()
452                .unwrap_or_else(std::env::temp_dir)
453                .join(&raw[2..])
454        } else {
455            path.to_path_buf()
456        }
457    } else {
458        path.to_path_buf()
459    };
460    let absolute = if expanded.is_absolute() {
461        expanded
462    } else {
463        std::env::current_dir()
464            .unwrap_or_else(|_| std::env::temp_dir())
465            .join(expanded)
466    };
467    normalize_absolute_path(&absolute)
468}
469
470fn normalize_absolute_path(path: &std::path::Path) -> PathBuf {
471    use std::path::Component;
472
473    let mut normalized = PathBuf::new();
474    for component in path.components() {
475        match component {
476            Component::CurDir => {}
477            Component::ParentDir => {
478                if !normalized.pop() {
479                    normalized.push(component.as_os_str());
480                }
481            }
482            other => normalized.push(other.as_os_str()),
483        }
484    }
485    normalized
486}
487
488pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
489    let root_tasks = storage_root.join("bash-tasks");
490    if !dir_has_entries(&root_tasks) {
491        return;
492    }
493
494    let harness_tasks = storage_root
495        .join(harness.storage_segment())
496        .join("bash-tasks");
497    if dir_has_entries(&harness_tasks) {
498        return;
499    }
500    if let Some(parent) = harness_tasks.parent() {
501        if let Err(error) = std::fs::create_dir_all(parent) {
502            crate::slog_warn!(
503                "failed to create harness bash task dir {}: {}",
504                parent.display(),
505                error
506            );
507            return;
508        }
509    }
510    if harness_tasks.exists() {
511        let _ = std::fs::remove_dir(&harness_tasks);
512    }
513
514    match std::fs::rename(&root_tasks, &harness_tasks) {
515        Ok(()) => crate::slog_info!(
516            "moved legacy root bash tasks into harness namespace: {}",
517            harness_tasks.display()
518        ),
519        Err(error) => {
520            crate::slog_warn!(
521                "failed to move legacy root bash tasks into {}: {}; trying child merge",
522                harness_tasks.display(),
523                error
524            );
525            if std::fs::create_dir_all(&harness_tasks).is_err() {
526                return;
527            }
528            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
529                for entry in entries.flatten() {
530                    let source = entry.path();
531                    let target = harness_tasks.join(entry.file_name());
532                    if !target.exists() {
533                        let _ = std::fs::rename(source, target);
534                    }
535                }
536            }
537            let _ = std::fs::remove_dir(&root_tasks);
538        }
539    }
540}
541
542fn dir_has_entries(path: &std::path::Path) -> bool {
543    std::fs::read_dir(path)
544        .map(|mut entries| entries.next().is_some())
545        .unwrap_or(false)
546}
547
548#[cfg(test)]
549mod storage_root_tests {
550    use std::ffi::{OsStr, OsString};
551    use std::panic::{catch_unwind, AssertUnwindSafe};
552    use std::path::Path;
553
554    struct NonPanickingCleanup<F: FnOnce()> {
555        cleanup: Option<F>,
556    }
557
558    impl<F: FnOnce()> NonPanickingCleanup<F> {
559        fn new(cleanup: F) -> Self {
560            Self {
561                cleanup: Some(cleanup),
562            }
563        }
564    }
565
566    impl<F: FnOnce()> Drop for NonPanickingCleanup<F> {
567        fn drop(&mut self) {
568            let Some(cleanup) = self.cleanup.take() else {
569                return;
570            };
571            // A cleanup panic while the test is already unwinding aborts the whole
572            // libtest process, so cleanup failures must remain contained here.
573            let _ = catch_unwind(AssertUnwindSafe(cleanup));
574        }
575    }
576
577    struct StorageEnvGuard {
578        previous: Vec<(&'static str, Option<OsString>)>,
579    }
580
581    impl StorageEnvGuard {
582        fn capture() -> Self {
583            Self {
584                previous: [
585                    "AFT_STORAGE_DIR",
586                    "AFT_CACHE_DIR",
587                    "XDG_DATA_HOME",
588                    "HOME",
589                    "USERPROFILE",
590                    "LOCALAPPDATA",
591                    "APPDATA",
592                ]
593                .into_iter()
594                .map(|key| (key, std::env::var_os(key)))
595                .collect(),
596            }
597        }
598
599        fn set(&self, key: &'static str, value: Option<&OsStr>) {
600            match value {
601                Some(value) => std::env::set_var(key, value),
602                None => std::env::remove_var(key),
603            }
604        }
605    }
606
607    impl Drop for StorageEnvGuard {
608        fn drop(&mut self) {
609            let previous: Vec<_> = self.previous.drain(..).collect();
610            // Env restoration runs while a failing test may already be
611            // unwinding; a cleanup panic at that point aborts the whole
612            // libtest process (observed on Windows CI), so the restore loop
613            // stays contained like NonPanickingCleanup above.
614            let _ = catch_unwind(AssertUnwindSafe(move || {
615                for (key, value) in previous {
616                    match value {
617                        Some(value) => std::env::set_var(key, value),
618                        None => std::env::remove_var(key),
619                    }
620                }
621            }));
622        }
623    }
624
625    // The plugin-injected and plugin-less paths must use one resolver so every
626    // artifact lane sees the same absolute root under every environment arm.
627    #[test]
628    fn fallback_and_injected_roots_agree_with_storage_override_arms() {
629        let _env_lock = crate::test_env::process_env_lock();
630        let env = StorageEnvGuard::capture();
631        let base = tempfile::tempdir().expect("storage root test directory");
632        let data_home = base.path().join("data");
633        let home = base.path().join("home");
634        let expected_plugin_root = data_home.join("cortexkit").join("aft");
635        let cache_root = base.path().join("legacy-cache");
636        env.set("XDG_DATA_HOME", Some(data_home.as_os_str()));
637        env.set("HOME", Some(home.as_os_str()));
638        env.set("USERPROFILE", Some(home.as_os_str()));
639        // The fallback==injected agreement is proven with the cache lever
640        // unset: plugin flows always pass `configured`, so AFT_CACHE_DIR
641        // only ever steers plugin-less (sandbox/test) invocations and ranks
642        // above the computed data root for exactly that purpose.
643        env.set("AFT_CACHE_DIR", None);
644        env.set("AFT_STORAGE_DIR", None);
645
646        assert_eq!(super::storage_dir(None), expected_plugin_root);
647
648        env.set("AFT_CACHE_DIR", Some(cache_root.as_os_str()));
649        assert_eq!(super::storage_dir(None), cache_root.join("aft"));
650        assert_eq!(
651            super::storage_dir(Some(&expected_plugin_root)),
652            expected_plugin_root,
653            "configured root must outrank the cache lever"
654        );
655        env.set("AFT_CACHE_DIR", None);
656        assert_eq!(
657            super::storage_dir(Some(&expected_plugin_root)),
658            expected_plugin_root
659        );
660        assert_eq!(
661            crate::search_index::resolve_cache_dir(Path::new("/tmp/project"), None)
662                .parent()
663                .and_then(Path::parent),
664            Some(expected_plugin_root.as_path())
665        );
666
667        let spelled_dir = base.path().join("spelled");
668        std::fs::create_dir(&spelled_dir).expect("spelled path component");
669        let explicit_spelling = spelled_dir.join("..");
670        assert_eq!(
671            super::storage_dir(Some(&explicit_spelling)),
672            explicit_spelling
673        );
674        assert_eq!(
675            crate::search_index::resolve_cache_dir(
676                Path::new("/tmp/project"),
677                Some(&explicit_spelling)
678            ),
679            explicit_spelling
680                .join("index")
681                .join(crate::search_index::artifact_cache_key(Path::new(
682                    "/tmp/project"
683                )))
684        );
685
686        env.set(
687            "AFT_STORAGE_DIR",
688            Some(OsStr::new("./relative/../local-aft-storage")),
689        );
690        let expected_relative = super::resolve_storage_path(Path::new("./local-aft-storage"));
691        assert!(expected_relative.is_absolute());
692        assert_eq!(super::storage_dir(None), expected_relative);
693        assert_eq!(
694            super::storage_dir(Some(&expected_plugin_root)),
695            expected_relative
696        );
697
698        env.set("AFT_STORAGE_DIR", Some(OsStr::new("")));
699        assert_eq!(super::storage_dir(None), expected_plugin_root);
700        assert_eq!(
701            super::storage_dir(Some(&expected_plugin_root)),
702            expected_plugin_root
703        );
704
705        env.set("AFT_STORAGE_DIR", Some(OsStr::new("~/tilde-aft-storage")));
706        let expected_tilde = home.join("tilde-aft-storage");
707        assert_eq!(super::storage_dir(None), expected_tilde);
708        assert_eq!(
709            super::storage_dir(Some(&expected_plugin_root)),
710            expected_tilde
711        );
712    }
713
714    #[test]
715    fn powershell_absence_has_an_honest_install_remedy() {
716        let error = super::resolve_powershell_path_with(|_| None).expect_err("pwsh is absent");
717        assert!(error.contains("PowerShell (pwsh) is not installed"));
718        assert!(error.contains("https://aka.ms/powershell"));
719    }
720
721    #[test]
722    fn cleanup_panic_during_unwind_does_not_abort_libtest() {
723        use std::sync::atomic::{AtomicBool, Ordering};
724
725        let cleanup_ran = AtomicBool::new(false);
726        let unwind = catch_unwind(AssertUnwindSafe(|| {
727            let _cleanup = NonPanickingCleanup::new(|| {
728                cleanup_ran.store(true, Ordering::SeqCst);
729                panic!("forced cleanup failure");
730            });
731            panic!("primary test failure");
732        }));
733
734        assert!(cleanup_ran.load(Ordering::SeqCst));
735        assert_eq!(
736            unwind
737                .expect_err("primary panic must escape the inner scope")
738                .downcast_ref::<&str>(),
739            Some(&"primary test failure")
740        );
741    }
742}