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#[cfg(unix)]
34pub(crate) fn resolved_shell_path(pty: bool) -> PathBuf {
35    if pty {
36        pty_process::resolve_posix_shell()
37    } else {
38        registry::resolve_posix_shell()
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct BgTaskInfo {
44    pub task_id: String,
45    pub status: BgTaskStatus,
46    pub command: String,
47    pub mode: BgMode,
48    pub started_at: u64,
49    pub duration_ms: Option<u64>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub status_reason: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum BgTaskStatus {
57    Starting,
58    Running,
59    Killing,
60    Completed,
61    Failed,
62    Killed,
63    TimedOut,
64    FateUnknown,
65}
66
67impl BgTaskStatus {
68    pub fn is_terminal(&self) -> bool {
69        matches!(
70            self,
71            BgTaskStatus::Completed
72                | BgTaskStatus::Failed
73                | BgTaskStatus::Killed
74                | BgTaskStatus::TimedOut
75                | BgTaskStatus::FateUnknown
76        )
77    }
78}
79
80/// Spawn a bash command in the background. Returns a task_id immediately.
81#[allow(clippy::too_many_arguments)]
82pub fn spawn(
83    request_id: &str,
84    session_id: &str,
85    command: &str,
86    workdir: Option<PathBuf>,
87    env: Option<HashMap<String, String>>,
88    timeout_ms: Option<u64>,
89    ctx: &AppContext,
90    require_background_flag: bool,
91    notify_on_completion: bool,
92    compressed: bool,
93    pty: bool,
94    pty_rows: u16,
95    pty_cols: u16,
96    scanner_report: Vec<PermissionAsk>,
97    host_escalation: Option<HostEscalationAttempt>,
98) -> Response {
99    if require_background_flag && !ctx.config().experimental_bash_background {
100        return Response::error(
101            request_id,
102            "feature_disabled",
103            "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
104        );
105    }
106
107    let workdir = workdir.unwrap_or_else(|| {
108        ctx.config().project_root.clone().unwrap_or_else(|| {
109            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
110        })
111    });
112    let storage_dir = task_storage_dir(ctx);
113    let max_running = ctx.config().max_background_bash_tasks;
114    let timeout = timeout_ms.map(Duration::from_millis);
115    let project_root = ctx
116        .config()
117        .project_root
118        .clone()
119        .or_else(|| std::env::current_dir().ok())
120        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
121
122    let mut env = env.unwrap_or_default();
123    let config = ctx.config();
124    let child_storage_root = self::storage_dir(config.storage_dir.as_deref());
125    if let Err(error) =
126        crate::agent_child_env::inject(config.as_ref(), &child_storage_root, &mut env)
127    {
128        return Response::error(request_id, "child_environment_unavailable", error);
129    }
130    let task_kind = if pty {
131        SandboxTaskKind::BashPty
132    } else if require_background_flag {
133        SandboxTaskKind::BashBackground
134    } else {
135        SandboxTaskKind::BashForeground
136    };
137    let principal = current_authenticated_principal();
138    let requested_tier = if host_escalation.is_some() {
139        RequestedSandboxTier::Host
140    } else if ctx.config().sandbox.enabled {
141        RequestedSandboxTier::Native
142    } else {
143        RequestedSandboxTier::Disabled
144    };
145    let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
146    #[cfg(unix)]
147    let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
148        && host_escalation.is_none()
149    {
150        let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
151            Ok(task) => task,
152            Err(error) => {
153                return Response::error(
154                    request_id,
155                    "sandbox_unavailable",
156                    format!(
157                        "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
158                    ),
159                );
160            }
161        };
162        let plan = resolve_sandbox_spawn(
163            ctx,
164            &principal,
165            requested_tier,
166            task_kind,
167            &task.paths.io_dir,
168            None,
169        );
170        if plan.refusal_code().is_some() {
171            (plan, Some(task))
172        } else {
173            let shell_path = resolved_shell_path(pty);
174            let root = project_root.as_deref().unwrap_or(&workdir);
175            let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
176            match crate::sandbox_spawn::prepare_task_payload(
177                &task,
178                command.as_bytes(),
179                root,
180                &workdir,
181                &principal,
182                &shell_path,
183                &environment,
184            ) {
185                Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
186                Err(error) => {
187                    let _ = persistence::delete_resolved_task(&task);
188                    return Response::error(
189                        request_id,
190                        "sandbox_unavailable",
191                        format!("native sandbox failed to materialize task payload: {error}"),
192                    );
193                }
194            }
195        }
196    } else {
197        (
198            resolve_sandbox_spawn(
199                ctx,
200                &principal,
201                requested_tier,
202                task_kind,
203                &session_dir,
204                host_escalation.as_ref(),
205            ),
206            None,
207        )
208    };
209    #[cfg(not(unix))]
210    let spawn_plan = resolve_sandbox_spawn(
211        ctx,
212        &principal,
213        requested_tier,
214        task_kind,
215        &session_dir,
216        host_escalation.as_ref(),
217    );
218    if let Some(code) = spawn_plan.refusal_code() {
219        #[cfg(unix)]
220        if let Some(task) = unregistered_task.as_ref() {
221            let _ = persistence::delete_resolved_task(task);
222        }
223        let message = spawn_plan
224            .refusal_message()
225            .unwrap_or("bash process creation refused by sandbox policy");
226        return match spawn_plan.refusal_mismatch_class() {
227            Some(class) => Response::error_with_data(
228                request_id,
229                code,
230                message,
231                json!({ "mismatch_class": class }),
232            ),
233            None => Response::error(request_id, code, message),
234        };
235    }
236
237    let cleanup_plan = spawn_plan.clone();
238    let spawn_result = if pty {
239        ctx.bash_background().spawn_pty(
240            spawn_plan,
241            command,
242            session_id.to_string(),
243            workdir,
244            env,
245            timeout,
246            storage_dir,
247            max_running,
248            notify_on_completion,
249            compressed,
250            project_root,
251            pty_rows,
252            pty_cols,
253        )
254    } else {
255        ctx.bash_background().spawn(
256            spawn_plan,
257            command,
258            session_id.to_string(),
259            workdir,
260            env,
261            timeout,
262            storage_dir,
263            max_running,
264            notify_on_completion,
265            compressed,
266            project_root,
267        )
268    };
269
270    match spawn_result {
271        Ok(task_id) => {
272            if let Err(error) =
273                ctx.bash_background()
274                    .record_scanner_report(&task_id, session_id, scanner_report)
275            {
276                crate::slog_warn!("{error}");
277            }
278            Response::success(
279                request_id,
280                json!({
281                    "task_id": task_id,
282                    "status": BgTaskStatus::Running,
283                    "mode": if pty { "pty" } else { "pipes" },
284                }),
285            )
286        }
287        Err(message) if message.contains("limit exceeded") => {
288            cleanup_plan.cleanup_unspawned();
289            #[cfg(unix)]
290            if let Some(task) = unregistered_task.as_ref() {
291                let _ = persistence::delete_resolved_task(task);
292            }
293            Response::error(request_id, "background_task_limit_exceeded", message)
294        }
295        Err(message) => {
296            cleanup_plan.cleanup_unspawned();
297            #[cfg(unix)]
298            if let Some(task) = unregistered_task.as_ref() {
299                let _ = persistence::delete_resolved_task(task);
300            }
301            if cleanup_plan.is_native_launcher() {
302                Response::error(
303                    request_id,
304                    "sandbox_unavailable",
305                    format!(
306                        "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
307                    ),
308                )
309            } else {
310                Response::error(request_id, "execution_failed", message)
311            }
312        }
313    }
314}
315
316pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
317    let config = ctx.config();
318    let root = storage_dir(config.storage_dir.as_deref());
319    config
320        .harness
321        .as_ref()
322        .map(|harness| root.join(harness.storage_segment()))
323        .unwrap_or(root)
324}
325
326/// Resolve the process-state storage root exactly once for every Rust entry point.
327/// The environment override is checked here so it wins over a stale plugin wire
328/// value, while both plugin-less fallback and plugin-injected paths share one root.
329pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
330    if let Some(dir) = non_empty_env_path("AFT_STORAGE_DIR") {
331        return resolve_storage_path(&dir);
332    }
333    if let Some(dir) = configured {
334        // Explicit process-state paths are already caller-owned. Preserve their
335        // spelling so every downstream read/write uses the exact configured root.
336        return dir.to_path_buf();
337    }
338    // AFT_CACHE_DIR outranks the computed data root: it predates
339    // AFT_STORAGE_DIR as the storage sandbox lever, and the data root is
340    // effectively always derivable (any HOME yields one), so ranking it
341    // higher would leave the cache-dir arm permanently dead and leak
342    // sandboxed fixtures onto the real machine root.
343    if let Some(dir) = non_empty_env_path("AFT_CACHE_DIR") {
344        return resolve_storage_path(&dir).join("aft");
345    }
346    if let Some(root) = cortexkit_data_root() {
347        return root.join("cortexkit").join("aft");
348    }
349    std::env::temp_dir().join("cortexkit").join("aft")
350}
351
352fn non_empty_env_path(name: &str) -> Option<PathBuf> {
353    std::env::var_os(name)
354        .filter(|value| !value.is_empty())
355        .map(PathBuf::from)
356}
357
358fn storage_home_dir() -> Option<PathBuf> {
359    let configured = if cfg!(windows) {
360        non_empty_env_path("USERPROFILE").or_else(|| non_empty_env_path("HOME"))
361    } else {
362        non_empty_env_path("HOME").or_else(|| non_empty_env_path("USERPROFILE"))
363    };
364    configured.or_else(std::env::home_dir)
365}
366
367fn cortexkit_data_root() -> Option<PathBuf> {
368    if let Some(dir) = non_empty_env_path("XDG_DATA_HOME") {
369        return Some(resolve_storage_path(&dir));
370    }
371    if cfg!(windows) {
372        if let Some(dir) = std::env::var_os("LOCALAPPDATA")
373            .filter(|value| !value.is_empty())
374            .or_else(|| std::env::var_os("APPDATA").filter(|value| !value.is_empty()))
375            .map(PathBuf::from)
376        {
377            return Some(resolve_storage_path(&dir));
378        }
379    }
380    storage_home_dir().map(|home| {
381        let root = if cfg!(windows) {
382            home.join("AppData").join("Local")
383        } else {
384            home.join(".local").join("share")
385        };
386        resolve_storage_path(&root)
387    })
388}
389
390fn resolve_storage_path(path: &std::path::Path) -> PathBuf {
391    let expanded = if path == std::path::Path::new("~") {
392        storage_home_dir().unwrap_or_else(std::env::temp_dir)
393    } else if let Some(raw) = path.to_str() {
394        if raw.starts_with("~/") || raw.starts_with("~\\") {
395            storage_home_dir()
396                .unwrap_or_else(std::env::temp_dir)
397                .join(&raw[2..])
398        } else {
399            path.to_path_buf()
400        }
401    } else {
402        path.to_path_buf()
403    };
404    let absolute = if expanded.is_absolute() {
405        expanded
406    } else {
407        std::env::current_dir()
408            .unwrap_or_else(|_| std::env::temp_dir())
409            .join(expanded)
410    };
411    normalize_absolute_path(&absolute)
412}
413
414fn normalize_absolute_path(path: &std::path::Path) -> PathBuf {
415    use std::path::Component;
416
417    let mut normalized = PathBuf::new();
418    for component in path.components() {
419        match component {
420            Component::CurDir => {}
421            Component::ParentDir => {
422                if !normalized.pop() {
423                    normalized.push(component.as_os_str());
424                }
425            }
426            other => normalized.push(other.as_os_str()),
427        }
428    }
429    normalized
430}
431
432pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
433    let root_tasks = storage_root.join("bash-tasks");
434    if !dir_has_entries(&root_tasks) {
435        return;
436    }
437
438    let harness_tasks = storage_root
439        .join(harness.storage_segment())
440        .join("bash-tasks");
441    if dir_has_entries(&harness_tasks) {
442        return;
443    }
444    if let Some(parent) = harness_tasks.parent() {
445        if let Err(error) = std::fs::create_dir_all(parent) {
446            crate::slog_warn!(
447                "failed to create harness bash task dir {}: {}",
448                parent.display(),
449                error
450            );
451            return;
452        }
453    }
454    if harness_tasks.exists() {
455        let _ = std::fs::remove_dir(&harness_tasks);
456    }
457
458    match std::fs::rename(&root_tasks, &harness_tasks) {
459        Ok(()) => crate::slog_info!(
460            "moved legacy root bash tasks into harness namespace: {}",
461            harness_tasks.display()
462        ),
463        Err(error) => {
464            crate::slog_warn!(
465                "failed to move legacy root bash tasks into {}: {}; trying child merge",
466                harness_tasks.display(),
467                error
468            );
469            if std::fs::create_dir_all(&harness_tasks).is_err() {
470                return;
471            }
472            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
473                for entry in entries.flatten() {
474                    let source = entry.path();
475                    let target = harness_tasks.join(entry.file_name());
476                    if !target.exists() {
477                        let _ = std::fs::rename(source, target);
478                    }
479                }
480            }
481            let _ = std::fs::remove_dir(&root_tasks);
482        }
483    }
484}
485
486fn dir_has_entries(path: &std::path::Path) -> bool {
487    std::fs::read_dir(path)
488        .map(|mut entries| entries.next().is_some())
489        .unwrap_or(false)
490}
491
492#[cfg(test)]
493mod storage_root_tests {
494    use std::ffi::{OsStr, OsString};
495    use std::panic::{catch_unwind, AssertUnwindSafe};
496    use std::path::Path;
497
498    struct NonPanickingCleanup<F: FnOnce()> {
499        cleanup: Option<F>,
500    }
501
502    impl<F: FnOnce()> NonPanickingCleanup<F> {
503        fn new(cleanup: F) -> Self {
504            Self {
505                cleanup: Some(cleanup),
506            }
507        }
508    }
509
510    impl<F: FnOnce()> Drop for NonPanickingCleanup<F> {
511        fn drop(&mut self) {
512            let Some(cleanup) = self.cleanup.take() else {
513                return;
514            };
515            // A cleanup panic while the test is already unwinding aborts the whole
516            // libtest process, so cleanup failures must remain contained here.
517            let _ = catch_unwind(AssertUnwindSafe(cleanup));
518        }
519    }
520
521    struct StorageEnvGuard {
522        previous: Vec<(&'static str, Option<OsString>)>,
523    }
524
525    impl StorageEnvGuard {
526        fn capture() -> Self {
527            Self {
528                previous: [
529                    "AFT_STORAGE_DIR",
530                    "AFT_CACHE_DIR",
531                    "XDG_DATA_HOME",
532                    "HOME",
533                    "USERPROFILE",
534                    "LOCALAPPDATA",
535                    "APPDATA",
536                ]
537                .into_iter()
538                .map(|key| (key, std::env::var_os(key)))
539                .collect(),
540            }
541        }
542
543        fn set(&self, key: &'static str, value: Option<&OsStr>) {
544            match value {
545                Some(value) => std::env::set_var(key, value),
546                None => std::env::remove_var(key),
547            }
548        }
549    }
550
551    impl Drop for StorageEnvGuard {
552        fn drop(&mut self) {
553            let previous: Vec<_> = self.previous.drain(..).collect();
554            // Env restoration runs while a failing test may already be
555            // unwinding; a cleanup panic at that point aborts the whole
556            // libtest process (observed on Windows CI), so the restore loop
557            // stays contained like NonPanickingCleanup above.
558            let _ = catch_unwind(AssertUnwindSafe(move || {
559                for (key, value) in previous {
560                    match value {
561                        Some(value) => std::env::set_var(key, value),
562                        None => std::env::remove_var(key),
563                    }
564                }
565            }));
566        }
567    }
568
569    // The plugin-injected and plugin-less paths must use one resolver so every
570    // artifact lane sees the same absolute root under every environment arm.
571    #[test]
572    fn fallback_and_injected_roots_agree_with_storage_override_arms() {
573        let _env_lock = crate::test_env::process_env_lock();
574        let env = StorageEnvGuard::capture();
575        let base = tempfile::tempdir().expect("storage root test directory");
576        let data_home = base.path().join("data");
577        let home = base.path().join("home");
578        let expected_plugin_root = data_home.join("cortexkit").join("aft");
579        let cache_root = base.path().join("legacy-cache");
580        env.set("XDG_DATA_HOME", Some(data_home.as_os_str()));
581        env.set("HOME", Some(home.as_os_str()));
582        env.set("USERPROFILE", Some(home.as_os_str()));
583        // The fallback==injected agreement is proven with the cache lever
584        // unset: plugin flows always pass `configured`, so AFT_CACHE_DIR
585        // only ever steers plugin-less (sandbox/test) invocations and ranks
586        // above the computed data root for exactly that purpose.
587        env.set("AFT_CACHE_DIR", None);
588        env.set("AFT_STORAGE_DIR", None);
589
590        assert_eq!(super::storage_dir(None), expected_plugin_root);
591
592        env.set("AFT_CACHE_DIR", Some(cache_root.as_os_str()));
593        assert_eq!(super::storage_dir(None), cache_root.join("aft"));
594        assert_eq!(
595            super::storage_dir(Some(&expected_plugin_root)),
596            expected_plugin_root,
597            "configured root must outrank the cache lever"
598        );
599        env.set("AFT_CACHE_DIR", None);
600        assert_eq!(
601            super::storage_dir(Some(&expected_plugin_root)),
602            expected_plugin_root
603        );
604        assert_eq!(
605            crate::search_index::resolve_cache_dir(Path::new("/tmp/project"), None)
606                .parent()
607                .and_then(Path::parent),
608            Some(expected_plugin_root.as_path())
609        );
610
611        let spelled_dir = base.path().join("spelled");
612        std::fs::create_dir(&spelled_dir).expect("spelled path component");
613        let explicit_spelling = spelled_dir.join("..");
614        assert_eq!(
615            super::storage_dir(Some(&explicit_spelling)),
616            explicit_spelling
617        );
618        assert_eq!(
619            crate::search_index::resolve_cache_dir(
620                Path::new("/tmp/project"),
621                Some(&explicit_spelling)
622            ),
623            explicit_spelling
624                .join("index")
625                .join(crate::search_index::artifact_cache_key(Path::new(
626                    "/tmp/project"
627                )))
628        );
629
630        env.set(
631            "AFT_STORAGE_DIR",
632            Some(OsStr::new("./relative/../local-aft-storage")),
633        );
634        let expected_relative = super::resolve_storage_path(Path::new("./local-aft-storage"));
635        assert!(expected_relative.is_absolute());
636        assert_eq!(super::storage_dir(None), expected_relative);
637        assert_eq!(
638            super::storage_dir(Some(&expected_plugin_root)),
639            expected_relative
640        );
641
642        env.set("AFT_STORAGE_DIR", Some(OsStr::new("")));
643        assert_eq!(super::storage_dir(None), expected_plugin_root);
644        assert_eq!(
645            super::storage_dir(Some(&expected_plugin_root)),
646            expected_plugin_root
647        );
648
649        env.set("AFT_STORAGE_DIR", Some(OsStr::new("~/tilde-aft-storage")));
650        let expected_tilde = home.join("tilde-aft-storage");
651        assert_eq!(super::storage_dir(None), expected_tilde);
652        assert_eq!(
653            super::storage_dir(Some(&expected_plugin_root)),
654            expected_tilde
655        );
656    }
657
658    #[test]
659    fn cleanup_panic_during_unwind_does_not_abort_libtest() {
660        use std::sync::atomic::{AtomicBool, Ordering};
661
662        let cleanup_ran = AtomicBool::new(false);
663        let unwind = catch_unwind(AssertUnwindSafe(|| {
664            let _cleanup = NonPanickingCleanup::new(|| {
665                cleanup_ran.store(true, Ordering::SeqCst);
666                panic!("forced cleanup failure");
667            });
668            panic!("primary test failure");
669        }));
670
671        assert!(cleanup_ran.load(Ordering::SeqCst));
672        assert_eq!(
673            unwind
674                .expect_err("primary panic must escape the inner scope")
675                .downcast_ref::<&str>(),
676            Some(&"primary test failure")
677        );
678    }
679}