1pub 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#[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 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 #[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}