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 #[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#[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
326pub 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 return dir.to_path_buf();
337 }
338 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 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 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 #[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 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}