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}
65
66impl BgTaskStatus {
67    pub fn is_terminal(&self) -> bool {
68        matches!(
69            self,
70            BgTaskStatus::Completed
71                | BgTaskStatus::Failed
72                | BgTaskStatus::Killed
73                | BgTaskStatus::TimedOut
74        )
75    }
76}
77
78/// Spawn a bash command in the background. Returns a task_id immediately.
79#[allow(clippy::too_many_arguments)]
80pub fn spawn(
81    request_id: &str,
82    session_id: &str,
83    command: &str,
84    workdir: Option<PathBuf>,
85    env: Option<HashMap<String, String>>,
86    timeout_ms: Option<u64>,
87    ctx: &AppContext,
88    require_background_flag: bool,
89    notify_on_completion: bool,
90    compressed: bool,
91    pty: bool,
92    pty_rows: u16,
93    pty_cols: u16,
94    scanner_report: Vec<PermissionAsk>,
95    host_escalation: Option<HostEscalationAttempt>,
96) -> Response {
97    if require_background_flag && !ctx.config().experimental_bash_background {
98        return Response::error(
99            request_id,
100            "feature_disabled",
101            "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
102        );
103    }
104
105    let workdir = workdir.unwrap_or_else(|| {
106        ctx.config().project_root.clone().unwrap_or_else(|| {
107            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
108        })
109    });
110    let storage_dir = task_storage_dir(ctx);
111    let max_running = ctx.config().max_background_bash_tasks;
112    let timeout = timeout_ms.map(Duration::from_millis);
113    let project_root = ctx
114        .config()
115        .project_root
116        .clone()
117        .or_else(|| std::env::current_dir().ok())
118        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
119
120    let env = env.unwrap_or_default();
121    let task_kind = if pty {
122        SandboxTaskKind::BashPty
123    } else if require_background_flag {
124        SandboxTaskKind::BashBackground
125    } else {
126        SandboxTaskKind::BashForeground
127    };
128    let principal = current_authenticated_principal();
129    let requested_tier = if host_escalation.is_some() {
130        RequestedSandboxTier::Host
131    } else if ctx.config().sandbox.enabled {
132        RequestedSandboxTier::Native
133    } else {
134        RequestedSandboxTier::Disabled
135    };
136    let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
137    #[cfg(unix)]
138    let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
139        && host_escalation.is_none()
140    {
141        let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
142            Ok(task) => task,
143            Err(error) => {
144                return Response::error(
145                    request_id,
146                    "sandbox_unavailable",
147                    format!(
148                        "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
149                    ),
150                );
151            }
152        };
153        let plan = resolve_sandbox_spawn(
154            ctx,
155            &principal,
156            requested_tier,
157            task_kind,
158            &task.paths.io_dir,
159            None,
160        );
161        if plan.refusal_code().is_some() {
162            (plan, Some(task))
163        } else {
164            let shell_path = resolved_shell_path(pty);
165            let root = project_root.as_deref().unwrap_or(&workdir);
166            let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
167            match crate::sandbox_spawn::prepare_task_payload(
168                &task,
169                command.as_bytes(),
170                root,
171                &workdir,
172                &principal,
173                &shell_path,
174                &environment,
175            ) {
176                Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
177                Err(error) => {
178                    let _ = persistence::delete_resolved_task(&task);
179                    return Response::error(
180                        request_id,
181                        "sandbox_unavailable",
182                        format!("native sandbox failed to materialize task payload: {error}"),
183                    );
184                }
185            }
186        }
187    } else {
188        (
189            resolve_sandbox_spawn(
190                ctx,
191                &principal,
192                requested_tier,
193                task_kind,
194                &session_dir,
195                host_escalation.as_ref(),
196            ),
197            None,
198        )
199    };
200    #[cfg(not(unix))]
201    let spawn_plan = resolve_sandbox_spawn(
202        ctx,
203        &principal,
204        requested_tier,
205        task_kind,
206        &session_dir,
207        host_escalation.as_ref(),
208    );
209    if let Some(code) = spawn_plan.refusal_code() {
210        #[cfg(unix)]
211        if let Some(task) = unregistered_task.as_ref() {
212            let _ = persistence::delete_resolved_task(task);
213        }
214        let message = spawn_plan
215            .refusal_message()
216            .unwrap_or("bash process creation refused by sandbox policy");
217        return match spawn_plan.refusal_mismatch_class() {
218            Some(class) => Response::error_with_data(
219                request_id,
220                code,
221                message,
222                json!({ "mismatch_class": class }),
223            ),
224            None => Response::error(request_id, code, message),
225        };
226    }
227
228    let cleanup_plan = spawn_plan.clone();
229    let spawn_result = if pty {
230        ctx.bash_background().spawn_pty(
231            spawn_plan,
232            command,
233            session_id.to_string(),
234            workdir,
235            env,
236            timeout,
237            storage_dir,
238            max_running,
239            notify_on_completion,
240            compressed,
241            project_root,
242            pty_rows,
243            pty_cols,
244        )
245    } else {
246        ctx.bash_background().spawn(
247            spawn_plan,
248            command,
249            session_id.to_string(),
250            workdir,
251            env,
252            timeout,
253            storage_dir,
254            max_running,
255            notify_on_completion,
256            compressed,
257            project_root,
258        )
259    };
260
261    match spawn_result {
262        Ok(task_id) => {
263            if let Err(error) =
264                ctx.bash_background()
265                    .record_scanner_report(&task_id, session_id, scanner_report)
266            {
267                crate::slog_warn!("{error}");
268            }
269            Response::success(
270                request_id,
271                json!({
272                    "task_id": task_id,
273                    "status": BgTaskStatus::Running,
274                    "mode": if pty { "pty" } else { "pipes" },
275                }),
276            )
277        }
278        Err(message) if message.contains("limit exceeded") => {
279            cleanup_plan.cleanup_unspawned();
280            #[cfg(unix)]
281            if let Some(task) = unregistered_task.as_ref() {
282                let _ = persistence::delete_resolved_task(task);
283            }
284            Response::error(request_id, "background_task_limit_exceeded", message)
285        }
286        Err(message) => {
287            cleanup_plan.cleanup_unspawned();
288            #[cfg(unix)]
289            if let Some(task) = unregistered_task.as_ref() {
290                let _ = persistence::delete_resolved_task(task);
291            }
292            if cleanup_plan.is_native_launcher() {
293                Response::error(
294                    request_id,
295                    "sandbox_unavailable",
296                    format!(
297                        "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
298                    ),
299                )
300            } else {
301                Response::error(request_id, "execution_failed", message)
302            }
303        }
304    }
305}
306
307pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
308    let config = ctx.config();
309    let root = storage_dir(config.storage_dir.as_deref());
310    config
311        .harness
312        .as_ref()
313        .map(|harness| root.join(harness.storage_segment()))
314        .unwrap_or(root)
315}
316
317pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
318    if let Some(dir) = configured {
319        return dir.to_path_buf();
320    }
321    if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
322        return PathBuf::from(dir).join("aft");
323    }
324    // Default to the CortexKit shared data root — the SAME location the
325    // plugins inject as `storage_dir` on every configure. Before this, the
326    // fallback was the pre-migration `~/.cache/aft`, which only plugin-less
327    // invocations ever hit; once the daemon-supervised module became such an
328    // invocation it built a parallel storage universe there (duplicate
329    // trigram/semantic/callgraph caches AND a separate artifact-owner
330    // manifest, so cross-front leases could not see each other). Keep this
331    // in sync with resolveCortexKitStorageRoot in the TS packages.
332    cortexkit_data_root().join("cortexkit").join("aft")
333}
334
335fn cortexkit_data_root() -> PathBuf {
336    if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
337        if !dir.is_empty() {
338            return PathBuf::from(dir);
339        }
340    }
341    let home = std::env::var_os("HOME")
342        .or_else(|| std::env::var_os("USERPROFILE"))
343        .map(PathBuf::from)
344        .unwrap_or_else(std::env::temp_dir);
345    if cfg!(windows) {
346        return std::env::var_os("LOCALAPPDATA")
347            .or_else(|| std::env::var_os("APPDATA"))
348            .map(PathBuf::from)
349            .unwrap_or_else(|| home.join("AppData").join("Local"));
350    }
351    home.join(".local").join("share")
352}
353
354pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
355    let root_tasks = storage_root.join("bash-tasks");
356    if !dir_has_entries(&root_tasks) {
357        return;
358    }
359
360    let harness_tasks = storage_root
361        .join(harness.storage_segment())
362        .join("bash-tasks");
363    if dir_has_entries(&harness_tasks) {
364        return;
365    }
366    if let Some(parent) = harness_tasks.parent() {
367        if let Err(error) = std::fs::create_dir_all(parent) {
368            crate::slog_warn!(
369                "failed to create harness bash task dir {}: {}",
370                parent.display(),
371                error
372            );
373            return;
374        }
375    }
376    if harness_tasks.exists() {
377        let _ = std::fs::remove_dir(&harness_tasks);
378    }
379
380    match std::fs::rename(&root_tasks, &harness_tasks) {
381        Ok(()) => crate::slog_info!(
382            "moved legacy root bash tasks into harness namespace: {}",
383            harness_tasks.display()
384        ),
385        Err(error) => {
386            crate::slog_warn!(
387                "failed to move legacy root bash tasks into {}: {}; trying child merge",
388                harness_tasks.display(),
389                error
390            );
391            if std::fs::create_dir_all(&harness_tasks).is_err() {
392                return;
393            }
394            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
395                for entry in entries.flatten() {
396                    let source = entry.path();
397                    let target = harness_tasks.join(entry.file_name());
398                    if !target.exists() {
399                        let _ = std::fs::rename(source, target);
400                    }
401                }
402            }
403            let _ = std::fs::remove_dir(&root_tasks);
404        }
405    }
406}
407
408fn dir_has_entries(path: &std::path::Path) -> bool {
409    std::fs::read_dir(path)
410        .map(|mut entries| entries.next().is_some())
411        .unwrap_or(false)
412}
413
414#[cfg(test)]
415mod storage_root_tests {
416    use std::path::PathBuf;
417
418    // The plugins inject the CortexKit data root as `storage_dir` on every
419    // configure; plugin-less invocations (daemon-supervised module, bare CLI,
420    // warmup) hit the Rust fallback below instead. If the two ever diverge
421    // again, every front pays duplicate cold indexes AND artifact-owner
422    // manifests split across universes so cross-front ReadOnly leasing goes
423    // blind (the v0.45.x daemon-module regression: the fallback still pointed
424    // at pre-migration ~/.cache/aft). This locks all Rust fallback sites to
425    // the same resolution the TS packages use (resolveCortexKitStorageRoot:
426    // XDG_DATA_HOME || platform data dir, + cortexkit/aft).
427    #[test]
428    fn plugin_less_fallback_matches_plugin_injected_cortexkit_root() {
429        let _guard = crate::test_env::process_env_lock();
430        let data_home = std::env::var_os("XDG_DATA_HOME")
431            .filter(|value| !value.is_empty())
432            .map(PathBuf::from)
433            .unwrap_or_else(|| {
434                let home = std::env::var_os("HOME")
435                    .or_else(|| std::env::var_os("USERPROFILE"))
436                    .map(PathBuf::from)
437                    .expect("test environment provides a home directory");
438                if cfg!(windows) {
439                    std::env::var_os("LOCALAPPDATA")
440                        .or_else(|| std::env::var_os("APPDATA"))
441                        .map(PathBuf::from)
442                        .unwrap_or_else(|| home.join("AppData").join("Local"))
443                } else {
444                    home.join(".local").join("share")
445                }
446            });
447        let expected_plugin_injected_root = data_home.join("cortexkit").join("aft");
448
449        let cache_dir_override_absent = std::env::var_os("AFT_CACHE_DIR").is_none();
450        assert!(
451            cache_dir_override_absent,
452            "test requires AFT_CACHE_DIR unset to exercise the real fallback"
453        );
454
455        assert_eq!(
456            super::storage_dir(None),
457            expected_plugin_injected_root,
458            "bash_background::storage_dir fallback diverged from the plugin-injected root"
459        );
460
461        let temp_project = tempfile::tempdir().expect("temp project");
462        let resolved = crate::search_index::resolve_cache_dir(temp_project.path(), None);
463        assert!(
464            resolved.starts_with(&expected_plugin_injected_root),
465            "search_index::resolve_cache_dir fallback diverged: {}",
466            resolved.display()
467        );
468    }
469}