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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum BashShell {
36 #[default]
37 Bash,
38 Powershell,
39}
40
41impl BashShell {
42 pub(crate) fn is_powershell(self) -> bool {
43 matches!(self, Self::Powershell)
44 }
45
46 pub(crate) fn command_text(self, command: &str) -> String {
47 if self.is_powershell() {
48 format!(
52 "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding;\n{command}"
53 )
54 } else {
55 command.to_string()
56 }
57 }
58}
59
60fn resolve_powershell_path_with(
61 lookup: impl FnOnce(&str) -> Option<PathBuf>,
62) -> Result<PathBuf, String> {
63 #[cfg(windows)]
64 let candidate = "pwsh.exe";
65 #[cfg(not(windows))]
66 let candidate = "pwsh";
67 lookup(candidate).ok_or_else(|| {
68 "PowerShell (pwsh) is not installed or is not on PATH. Install PowerShell 7+: https://aka.ms/powershell"
69 .to_string()
70 })
71}
72
73pub(crate) fn resolve_shell_path(pty: bool, shell: BashShell) -> Result<PathBuf, String> {
74 if shell.is_powershell() {
75 return resolve_powershell_path_with(|candidate| which::which(candidate).ok());
76 }
77
78 #[cfg(unix)]
79 {
80 Ok(if pty {
81 pty_process::resolve_posix_shell()
82 } else {
83 registry::resolve_posix_shell()
84 })
85 }
86 #[cfg(windows)]
87 {
88 let _ = pty;
89 Ok(PathBuf::from("cmd.exe"))
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct BgTaskInfo {
95 pub task_id: String,
96 pub status: BgTaskStatus,
97 pub command: String,
98 pub mode: BgMode,
99 pub started_at: u64,
100 pub duration_ms: Option<u64>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub status_reason: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum BgTaskStatus {
108 Starting,
109 Running,
110 Killing,
111 Completed,
112 Failed,
113 Killed,
114 TimedOut,
115 FateUnknown,
116}
117
118impl BgTaskStatus {
119 pub fn is_terminal(&self) -> bool {
120 matches!(
121 self,
122 BgTaskStatus::Completed
123 | BgTaskStatus::Failed
124 | BgTaskStatus::Killed
125 | BgTaskStatus::TimedOut
126 | BgTaskStatus::FateUnknown
127 )
128 }
129}
130
131#[allow(clippy::too_many_arguments)]
133pub fn spawn(
134 request_id: &str,
135 session_id: &str,
136 command: &str,
137 shell: BashShell,
138 shell_path: PathBuf,
139 workdir: Option<PathBuf>,
140 env: Option<HashMap<String, String>>,
141 timeout_ms: Option<u64>,
142 ctx: &AppContext,
143 require_background_flag: bool,
144 notify_on_completion: bool,
145 compressed: bool,
146 pty: bool,
147 pty_rows: u16,
148 pty_cols: u16,
149 scanner_report: Vec<PermissionAsk>,
150 host_escalation: Option<HostEscalationAttempt>,
151) -> Response {
152 if require_background_flag && !ctx.config().experimental_bash_background {
153 return Response::error(
154 request_id,
155 "feature_disabled",
156 "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
157 );
158 }
159
160 let workdir = workdir.unwrap_or_else(|| {
161 ctx.config().project_root.clone().unwrap_or_else(|| {
162 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
163 })
164 });
165 let storage_dir = task_storage_dir(ctx);
166 let max_running = ctx.config().max_background_bash_tasks;
167 let timeout = timeout_ms.map(Duration::from_millis);
168 let project_root = ctx
169 .config()
170 .project_root
171 .clone()
172 .or_else(|| std::env::current_dir().ok())
173 .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
174
175 let mut env = env.unwrap_or_default();
176 let config = ctx.config();
177 let child_storage_root = self::storage_dir(config.storage_dir.as_deref());
178 if let Err(error) =
179 crate::agent_child_env::inject(config.as_ref(), &child_storage_root, &mut env)
180 {
181 return Response::error(request_id, "child_environment_unavailable", error);
182 }
183 let task_kind = if pty {
184 SandboxTaskKind::BashPty
185 } else if require_background_flag {
186 SandboxTaskKind::BashBackground
187 } else {
188 SandboxTaskKind::BashForeground
189 };
190 let principal = current_authenticated_principal();
191 let requested_tier = if host_escalation.is_some() {
192 RequestedSandboxTier::Host
193 } else if ctx.config().sandbox.enabled {
194 RequestedSandboxTier::Native
195 } else {
196 RequestedSandboxTier::Disabled
197 };
198 let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
199 #[cfg(unix)]
200 let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
201 && host_escalation.is_none()
202 {
203 let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
204 Ok(task) => task,
205 Err(error) => {
206 return Response::error(
207 request_id,
208 "sandbox_unavailable",
209 format!(
210 "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
211 ),
212 );
213 }
214 };
215 let plan = resolve_sandbox_spawn(
216 ctx,
217 &principal,
218 requested_tier,
219 task_kind,
220 &task.paths.io_dir,
221 None,
222 );
223 if plan.refusal_code().is_some() {
224 (plan, Some(task))
225 } else {
226 let root = project_root.as_deref().unwrap_or(&workdir);
227 let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
228 match crate::sandbox_spawn::prepare_task_payload(
229 &task,
230 command.as_bytes(),
231 root,
232 &workdir,
233 &principal,
234 &shell_path,
235 &environment,
236 ) {
237 Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
238 Err(error) => {
239 let _ = persistence::delete_resolved_task(&task);
240 return Response::error(
241 request_id,
242 "sandbox_unavailable",
243 format!("native sandbox failed to materialize task payload: {error}"),
244 );
245 }
246 }
247 }
248 } else {
249 (
250 resolve_sandbox_spawn(
251 ctx,
252 &principal,
253 requested_tier,
254 task_kind,
255 &session_dir,
256 host_escalation.as_ref(),
257 ),
258 None,
259 )
260 };
261 #[cfg(not(unix))]
262 let spawn_plan = resolve_sandbox_spawn(
263 ctx,
264 &principal,
265 requested_tier,
266 task_kind,
267 &session_dir,
268 host_escalation.as_ref(),
269 );
270 if let Some(code) = spawn_plan.refusal_code() {
271 #[cfg(unix)]
272 if let Some(task) = unregistered_task.as_ref() {
273 let _ = persistence::delete_resolved_task(task);
274 }
275 let message = spawn_plan
276 .refusal_message()
277 .unwrap_or("bash process creation refused by sandbox policy");
278 return match spawn_plan.refusal_mismatch_class() {
279 Some(class) => Response::error_with_data(
280 request_id,
281 code,
282 message,
283 json!({ "mismatch_class": class }),
284 ),
285 None => Response::error(request_id, code, message),
286 };
287 }
288
289 let cleanup_plan = spawn_plan.clone();
290 let spawn_result = if pty {
291 ctx.bash_background().spawn_pty_with_shell(
292 spawn_plan,
293 command,
294 shell,
295 shell_path,
296 session_id.to_string(),
297 workdir,
298 env,
299 timeout,
300 storage_dir,
301 max_running,
302 notify_on_completion,
303 compressed,
304 project_root,
305 pty_rows,
306 pty_cols,
307 )
308 } else {
309 ctx.bash_background().spawn_with_shell(
310 spawn_plan,
311 command,
312 shell,
313 shell_path,
314 session_id.to_string(),
315 workdir,
316 env,
317 timeout,
318 storage_dir,
319 max_running,
320 notify_on_completion,
321 compressed,
322 project_root,
323 )
324 };
325
326 match spawn_result {
327 Ok(task_id) => {
328 if let Err(error) =
329 ctx.bash_background()
330 .record_scanner_report(&task_id, session_id, scanner_report)
331 {
332 crate::slog_warn!("{error}");
333 }
334 Response::success(
335 request_id,
336 json!({
337 "task_id": task_id,
338 "status": BgTaskStatus::Running,
339 "mode": if pty { "pty" } else { "pipes" },
340 }),
341 )
342 }
343 Err(message) if message.contains("limit exceeded") => {
344 cleanup_plan.cleanup_unspawned();
345 #[cfg(unix)]
346 if let Some(task) = unregistered_task.as_ref() {
347 let _ = persistence::delete_resolved_task(task);
348 }
349 Response::error(request_id, "background_task_limit_exceeded", message)
350 }
351 Err(message) => {
352 cleanup_plan.cleanup_unspawned();
353 #[cfg(unix)]
354 if let Some(task) = unregistered_task.as_ref() {
355 let _ = persistence::delete_resolved_task(task);
356 }
357 if cleanup_plan.is_native_launcher() {
358 Response::error(
359 request_id,
360 "sandbox_unavailable",
361 format!(
362 "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
363 ),
364 )
365 } else {
366 Response::error(request_id, "execution_failed", message)
367 }
368 }
369 }
370}
371
372pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
373 let config = ctx.config();
374 let root = storage_dir(config.storage_dir.as_deref());
375 config
376 .harness
377 .as_ref()
378 .map(|harness| root.join(harness.storage_segment()))
379 .unwrap_or(root)
380}
381
382pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
386 if let Some(dir) = non_empty_env_path("AFT_STORAGE_DIR") {
387 return resolve_storage_path(&dir);
388 }
389 if let Some(dir) = configured {
390 return dir.to_path_buf();
393 }
394 if let Some(dir) = non_empty_env_path("AFT_CACHE_DIR") {
400 return resolve_storage_path(&dir).join("aft");
401 }
402 if let Some(root) = cortexkit_data_root() {
403 return root.join("cortexkit").join("aft");
404 }
405 std::env::temp_dir().join("cortexkit").join("aft")
406}
407
408fn non_empty_env_path(name: &str) -> Option<PathBuf> {
409 std::env::var_os(name)
410 .filter(|value| !value.is_empty())
411 .map(PathBuf::from)
412}
413
414fn storage_home_dir() -> Option<PathBuf> {
415 let configured = if cfg!(windows) {
416 non_empty_env_path("USERPROFILE").or_else(|| non_empty_env_path("HOME"))
417 } else {
418 non_empty_env_path("HOME").or_else(|| non_empty_env_path("USERPROFILE"))
419 };
420 configured.or_else(std::env::home_dir)
421}
422
423fn cortexkit_data_root() -> Option<PathBuf> {
424 if let Some(dir) = non_empty_env_path("XDG_DATA_HOME") {
425 return Some(resolve_storage_path(&dir));
426 }
427 if cfg!(windows) {
428 if let Some(dir) = std::env::var_os("LOCALAPPDATA")
429 .filter(|value| !value.is_empty())
430 .or_else(|| std::env::var_os("APPDATA").filter(|value| !value.is_empty()))
431 .map(PathBuf::from)
432 {
433 return Some(resolve_storage_path(&dir));
434 }
435 }
436 storage_home_dir().map(|home| {
437 let root = if cfg!(windows) {
438 home.join("AppData").join("Local")
439 } else {
440 home.join(".local").join("share")
441 };
442 resolve_storage_path(&root)
443 })
444}
445
446fn resolve_storage_path(path: &std::path::Path) -> PathBuf {
447 let expanded = if path == std::path::Path::new("~") {
448 storage_home_dir().unwrap_or_else(std::env::temp_dir)
449 } else if let Some(raw) = path.to_str() {
450 if raw.starts_with("~/") || raw.starts_with("~\\") {
451 storage_home_dir()
452 .unwrap_or_else(std::env::temp_dir)
453 .join(&raw[2..])
454 } else {
455 path.to_path_buf()
456 }
457 } else {
458 path.to_path_buf()
459 };
460 let absolute = if expanded.is_absolute() {
461 expanded
462 } else {
463 std::env::current_dir()
464 .unwrap_or_else(|_| std::env::temp_dir())
465 .join(expanded)
466 };
467 normalize_absolute_path(&absolute)
468}
469
470fn normalize_absolute_path(path: &std::path::Path) -> PathBuf {
471 use std::path::Component;
472
473 let mut normalized = PathBuf::new();
474 for component in path.components() {
475 match component {
476 Component::CurDir => {}
477 Component::ParentDir => {
478 if !normalized.pop() {
479 normalized.push(component.as_os_str());
480 }
481 }
482 other => normalized.push(other.as_os_str()),
483 }
484 }
485 normalized
486}
487
488pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
489 let root_tasks = storage_root.join("bash-tasks");
490 if !dir_has_entries(&root_tasks) {
491 return;
492 }
493
494 let harness_tasks = storage_root
495 .join(harness.storage_segment())
496 .join("bash-tasks");
497 if dir_has_entries(&harness_tasks) {
498 return;
499 }
500 if let Some(parent) = harness_tasks.parent() {
501 if let Err(error) = std::fs::create_dir_all(parent) {
502 crate::slog_warn!(
503 "failed to create harness bash task dir {}: {}",
504 parent.display(),
505 error
506 );
507 return;
508 }
509 }
510 if harness_tasks.exists() {
511 let _ = std::fs::remove_dir(&harness_tasks);
512 }
513
514 match std::fs::rename(&root_tasks, &harness_tasks) {
515 Ok(()) => crate::slog_info!(
516 "moved legacy root bash tasks into harness namespace: {}",
517 harness_tasks.display()
518 ),
519 Err(error) => {
520 crate::slog_warn!(
521 "failed to move legacy root bash tasks into {}: {}; trying child merge",
522 harness_tasks.display(),
523 error
524 );
525 if std::fs::create_dir_all(&harness_tasks).is_err() {
526 return;
527 }
528 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
529 for entry in entries.flatten() {
530 let source = entry.path();
531 let target = harness_tasks.join(entry.file_name());
532 if !target.exists() {
533 let _ = std::fs::rename(source, target);
534 }
535 }
536 }
537 let _ = std::fs::remove_dir(&root_tasks);
538 }
539 }
540}
541
542fn dir_has_entries(path: &std::path::Path) -> bool {
543 std::fs::read_dir(path)
544 .map(|mut entries| entries.next().is_some())
545 .unwrap_or(false)
546}
547
548#[cfg(test)]
549mod storage_root_tests {
550 use std::ffi::{OsStr, OsString};
551 use std::panic::{catch_unwind, AssertUnwindSafe};
552 use std::path::Path;
553
554 struct NonPanickingCleanup<F: FnOnce()> {
555 cleanup: Option<F>,
556 }
557
558 impl<F: FnOnce()> NonPanickingCleanup<F> {
559 fn new(cleanup: F) -> Self {
560 Self {
561 cleanup: Some(cleanup),
562 }
563 }
564 }
565
566 impl<F: FnOnce()> Drop for NonPanickingCleanup<F> {
567 fn drop(&mut self) {
568 let Some(cleanup) = self.cleanup.take() else {
569 return;
570 };
571 let _ = catch_unwind(AssertUnwindSafe(cleanup));
574 }
575 }
576
577 struct StorageEnvGuard {
578 previous: Vec<(&'static str, Option<OsString>)>,
579 }
580
581 impl StorageEnvGuard {
582 fn capture() -> Self {
583 Self {
584 previous: [
585 "AFT_STORAGE_DIR",
586 "AFT_CACHE_DIR",
587 "XDG_DATA_HOME",
588 "HOME",
589 "USERPROFILE",
590 "LOCALAPPDATA",
591 "APPDATA",
592 ]
593 .into_iter()
594 .map(|key| (key, std::env::var_os(key)))
595 .collect(),
596 }
597 }
598
599 fn set(&self, key: &'static str, value: Option<&OsStr>) {
600 match value {
601 Some(value) => std::env::set_var(key, value),
602 None => std::env::remove_var(key),
603 }
604 }
605 }
606
607 impl Drop for StorageEnvGuard {
608 fn drop(&mut self) {
609 let previous: Vec<_> = self.previous.drain(..).collect();
610 let _ = catch_unwind(AssertUnwindSafe(move || {
615 for (key, value) in previous {
616 match value {
617 Some(value) => std::env::set_var(key, value),
618 None => std::env::remove_var(key),
619 }
620 }
621 }));
622 }
623 }
624
625 #[test]
628 fn fallback_and_injected_roots_agree_with_storage_override_arms() {
629 let _env_lock = crate::test_env::process_env_lock();
630 let env = StorageEnvGuard::capture();
631 let base = tempfile::tempdir().expect("storage root test directory");
632 let data_home = base.path().join("data");
633 let home = base.path().join("home");
634 let expected_plugin_root = data_home.join("cortexkit").join("aft");
635 let cache_root = base.path().join("legacy-cache");
636 env.set("XDG_DATA_HOME", Some(data_home.as_os_str()));
637 env.set("HOME", Some(home.as_os_str()));
638 env.set("USERPROFILE", Some(home.as_os_str()));
639 env.set("AFT_CACHE_DIR", None);
644 env.set("AFT_STORAGE_DIR", None);
645
646 assert_eq!(super::storage_dir(None), expected_plugin_root);
647
648 env.set("AFT_CACHE_DIR", Some(cache_root.as_os_str()));
649 assert_eq!(super::storage_dir(None), cache_root.join("aft"));
650 assert_eq!(
651 super::storage_dir(Some(&expected_plugin_root)),
652 expected_plugin_root,
653 "configured root must outrank the cache lever"
654 );
655 env.set("AFT_CACHE_DIR", None);
656 assert_eq!(
657 super::storage_dir(Some(&expected_plugin_root)),
658 expected_plugin_root
659 );
660 assert_eq!(
661 crate::search_index::resolve_cache_dir(Path::new("/tmp/project"), None)
662 .parent()
663 .and_then(Path::parent),
664 Some(expected_plugin_root.as_path())
665 );
666
667 let spelled_dir = base.path().join("spelled");
668 std::fs::create_dir(&spelled_dir).expect("spelled path component");
669 let explicit_spelling = spelled_dir.join("..");
670 assert_eq!(
671 super::storage_dir(Some(&explicit_spelling)),
672 explicit_spelling
673 );
674 assert_eq!(
675 crate::search_index::resolve_cache_dir(
676 Path::new("/tmp/project"),
677 Some(&explicit_spelling)
678 ),
679 explicit_spelling
680 .join("index")
681 .join(crate::search_index::artifact_cache_key(Path::new(
682 "/tmp/project"
683 )))
684 );
685
686 env.set(
687 "AFT_STORAGE_DIR",
688 Some(OsStr::new("./relative/../local-aft-storage")),
689 );
690 let expected_relative = super::resolve_storage_path(Path::new("./local-aft-storage"));
691 assert!(expected_relative.is_absolute());
692 assert_eq!(super::storage_dir(None), expected_relative);
693 assert_eq!(
694 super::storage_dir(Some(&expected_plugin_root)),
695 expected_relative
696 );
697
698 env.set("AFT_STORAGE_DIR", Some(OsStr::new("")));
699 assert_eq!(super::storage_dir(None), expected_plugin_root);
700 assert_eq!(
701 super::storage_dir(Some(&expected_plugin_root)),
702 expected_plugin_root
703 );
704
705 env.set("AFT_STORAGE_DIR", Some(OsStr::new("~/tilde-aft-storage")));
706 let expected_tilde = home.join("tilde-aft-storage");
707 assert_eq!(super::storage_dir(None), expected_tilde);
708 assert_eq!(
709 super::storage_dir(Some(&expected_plugin_root)),
710 expected_tilde
711 );
712 }
713
714 #[test]
715 fn powershell_absence_has_an_honest_install_remedy() {
716 let error = super::resolve_powershell_path_with(|_| None).expect_err("pwsh is absent");
717 assert!(error.contains("PowerShell (pwsh) is not installed"));
718 assert!(error.contains("https://aka.ms/powershell"));
719 }
720
721 #[test]
722 fn cleanup_panic_during_unwind_does_not_abort_libtest() {
723 use std::sync::atomic::{AtomicBool, Ordering};
724
725 let cleanup_ran = AtomicBool::new(false);
726 let unwind = catch_unwind(AssertUnwindSafe(|| {
727 let _cleanup = NonPanickingCleanup::new(|| {
728 cleanup_ran.store(true, Ordering::SeqCst);
729 panic!("forced cleanup failure");
730 });
731 panic!("primary test failure");
732 }));
733
734 assert!(cleanup_ran.load(Ordering::SeqCst));
735 assert_eq!(
736 unwind
737 .expect_err("primary panic must escape the inner scope")
738 .downcast_ref::<&str>(),
739 Some(&"primary test failure")
740 );
741 }
742}