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