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