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, WatchdogPassCause};
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 #[cfg(target_os = "linux")]
184 if !pty && config.bash.linux_scope {
185 env.insert(registry::LINUX_SCOPE_ENV.to_string(), "1".to_string());
186 }
187 let task_kind = if pty {
188 SandboxTaskKind::BashPty
189 } else if require_background_flag {
190 SandboxTaskKind::BashBackground
191 } else {
192 SandboxTaskKind::BashForeground
193 };
194 let principal = current_authenticated_principal();
195 let requested_tier = if host_escalation.is_some() {
196 RequestedSandboxTier::Host
197 } else if ctx.config().sandbox.enabled {
198 RequestedSandboxTier::Native
199 } else {
200 RequestedSandboxTier::Disabled
201 };
202 let session_dir = persistence::session_tasks_dir(&storage_dir, session_id);
203 #[cfg(unix)]
204 let (spawn_plan, unregistered_task) = if native_sandbox_enforced(ctx, &principal)
205 && host_escalation.is_none()
206 {
207 let task = match persistence::allocate_task_layout(&storage_dir, session_id) {
208 Ok(task) => task,
209 Err(error) => {
210 return Response::error(
211 request_id,
212 "sandbox_unavailable",
213 format!(
214 "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
215 ),
216 );
217 }
218 };
219 let plan = resolve_sandbox_spawn(
220 ctx,
221 &principal,
222 requested_tier,
223 task_kind,
224 &task.paths.io_dir,
225 None,
226 );
227 if plan.refusal_code().is_some() {
228 (plan, Some(task))
229 } else {
230 let root = project_root.as_deref().unwrap_or(&workdir);
231 let environment = crate::sandbox_spawn::approved_environment_for_plan(&plan, &env);
232 match crate::sandbox_spawn::prepare_task_payload(
233 &task,
234 command.as_bytes(),
235 root,
236 &workdir,
237 &principal,
238 &shell_path,
239 &environment,
240 ) {
241 Ok(prepared) => (plan.with_prepared_task(prepared), Some(task)),
242 Err(error) => {
243 let _ = persistence::delete_resolved_task(&task);
244 return Response::error(
245 request_id,
246 "sandbox_unavailable",
247 format!("native sandbox failed to materialize task payload: {error}"),
248 );
249 }
250 }
251 }
252 } else {
253 (
254 resolve_sandbox_spawn(
255 ctx,
256 &principal,
257 requested_tier,
258 task_kind,
259 &session_dir,
260 host_escalation.as_ref(),
261 ),
262 None,
263 )
264 };
265 #[cfg(not(unix))]
266 let spawn_plan = resolve_sandbox_spawn(
267 ctx,
268 &principal,
269 requested_tier,
270 task_kind,
271 &session_dir,
272 host_escalation.as_ref(),
273 );
274 if let Some(code) = spawn_plan.refusal_code() {
275 #[cfg(unix)]
276 if let Some(task) = unregistered_task.as_ref() {
277 let _ = persistence::delete_resolved_task(task);
278 }
279 let message = spawn_plan
280 .refusal_message()
281 .unwrap_or("bash process creation refused by sandbox policy");
282 return match spawn_plan.refusal_mismatch_class() {
283 Some(class) => Response::error_with_data(
284 request_id,
285 code,
286 message,
287 json!({ "mismatch_class": class }),
288 ),
289 None => Response::error(request_id, code, message),
290 };
291 }
292
293 let cleanup_plan = spawn_plan.clone();
294 let spawn_result = if pty {
295 ctx.bash_background().spawn_pty_with_shell(
296 spawn_plan,
297 command,
298 shell,
299 shell_path,
300 session_id.to_string(),
301 workdir,
302 env,
303 timeout,
304 storage_dir,
305 max_running,
306 notify_on_completion,
307 compressed,
308 project_root,
309 pty_rows,
310 pty_cols,
311 )
312 } else {
313 ctx.bash_background().spawn_with_shell(
314 spawn_plan,
315 command,
316 shell,
317 shell_path,
318 session_id.to_string(),
319 workdir,
320 env,
321 timeout,
322 storage_dir,
323 max_running,
324 notify_on_completion,
325 compressed,
326 project_root,
327 )
328 };
329
330 match spawn_result {
331 Ok(task_id) => {
332 if let Err(error) =
333 ctx.bash_background()
334 .record_scanner_report(&task_id, session_id, scanner_report)
335 {
336 crate::slog_warn!("{error}");
337 }
338 Response::success(
339 request_id,
340 json!({
341 "task_id": task_id,
342 "status": BgTaskStatus::Running,
343 "mode": if pty { "pty" } else { "pipes" },
344 }),
345 )
346 }
347 Err(message) if message.contains("limit exceeded") => {
348 cleanup_plan.cleanup_unspawned();
349 #[cfg(unix)]
350 if let Some(task) = unregistered_task.as_ref() {
351 let _ = persistence::delete_resolved_task(task);
352 }
353 Response::error(request_id, "background_task_limit_exceeded", message)
354 }
355 Err(message) => {
356 cleanup_plan.cleanup_unspawned();
357 #[cfg(unix)]
358 if let Some(task) = unregistered_task.as_ref() {
359 let _ = persistence::delete_resolved_task(task);
360 }
361 if cleanup_plan.is_native_launcher() {
362 Response::error(
363 request_id,
364 "sandbox_unavailable",
365 format!(
366 "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
367 ),
368 )
369 } else {
370 Response::error(request_id, "execution_failed", message)
371 }
372 }
373 }
374}
375
376pub(crate) fn task_storage_dir(ctx: &AppContext) -> PathBuf {
377 let config = ctx.config();
378 let root = storage_dir(config.storage_dir.as_deref());
379 config
380 .harness
381 .as_ref()
382 .map(|harness| root.join(harness.storage_segment()))
383 .unwrap_or(root)
384}
385
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387enum StoragePlatform {
388 Windows,
389 Other,
390}
391
392impl StoragePlatform {
393 fn current() -> Self {
394 if cfg!(windows) {
395 Self::Windows
396 } else {
397 Self::Other
398 }
399 }
400}
401
402pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
411 let lookup = |name: &str| std::env::var_os(name);
412 let fallback_home = std::env::home_dir();
413 let current_dir = std::env::current_dir().ok();
414 storage_dir_from(
415 configured,
416 &lookup,
417 StoragePlatform::current(),
418 fallback_home.as_deref(),
419 current_dir.as_deref(),
420 )
421}
422
423fn storage_dir_from(
424 configured: Option<&std::path::Path>,
425 lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
426 platform: StoragePlatform,
427 fallback_home: Option<&std::path::Path>,
428 current_dir: Option<&std::path::Path>,
429) -> PathBuf {
430 let resolve = |path: &std::path::Path| {
431 resolve_storage_path_from(path, lookup, platform, fallback_home, current_dir)
432 };
433
434 if let Some(dir) = non_empty_env_path_from(lookup, "AFT_STORAGE_DIR") {
435 return resolve(&dir);
436 }
437 if let Some(dir) = configured.filter(|path| !path.as_os_str().is_empty()) {
438 return dir.to_path_buf();
441 }
442 if let Some(dir) = non_empty_env_path_from(lookup, "AFT_CACHE_DIR") {
446 return resolve(&dir).join("aft");
447 }
448
449 resolve(&cortexkit_data_root_from(lookup, platform))
450 .join("cortexkit")
451 .join("aft")
452}
453
454fn non_empty_env_path_from(
455 lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
456 name: &str,
457) -> Option<PathBuf> {
458 lookup(name)
459 .filter(|value| !value.is_empty())
460 .map(PathBuf::from)
461}
462
463fn storage_home_dir_from(
464 lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
465 platform: StoragePlatform,
466 fallback_home: Option<&std::path::Path>,
467) -> Option<PathBuf> {
468 let configured = if platform == StoragePlatform::Windows {
469 non_empty_env_path_from(lookup, "USERPROFILE")
470 .or_else(|| non_empty_env_path_from(lookup, "HOME"))
471 } else {
472 non_empty_env_path_from(lookup, "HOME")
473 .or_else(|| non_empty_env_path_from(lookup, "USERPROFILE"))
474 };
475 configured.or_else(|| fallback_home.map(PathBuf::from))
476}
477
478fn cortexkit_data_root_from(
479 lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
480 platform: StoragePlatform,
481) -> PathBuf {
482 if let Some(dir) = non_empty_env_path_from(lookup, "XDG_DATA_HOME") {
483 return dir;
484 }
485 if platform == StoragePlatform::Windows {
486 if let Some(dir) = non_empty_env_path_from(lookup, "LOCALAPPDATA") {
490 return dir;
491 }
492 if let Some(home) = non_empty_env_path_from(lookup, "USERPROFILE") {
493 return home.join("AppData").join("Local");
494 }
495 }
496 if let Some(home) = non_empty_env_path_from(lookup, "HOME") {
497 return home.join(".local").join("share");
498 }
499 PathBuf::from(".local").join("share")
500}
501
502fn resolve_storage_path_from(
503 path: &std::path::Path,
504 lookup: &impl Fn(&str) -> Option<std::ffi::OsString>,
505 platform: StoragePlatform,
506 fallback_home: Option<&std::path::Path>,
507 current_dir: Option<&std::path::Path>,
508) -> PathBuf {
509 let storage_home = || storage_home_dir_from(lookup, platform, fallback_home);
510 let expanded = if path == std::path::Path::new("~") {
511 storage_home().unwrap_or_else(|| path.to_path_buf())
512 } else if let Some(raw) = path.to_str() {
513 if raw.starts_with("~/") || raw.starts_with("~\\") {
514 storage_home()
515 .map(|home| home.join(&raw[2..]))
516 .unwrap_or_else(|| path.to_path_buf())
517 } else {
518 path.to_path_buf()
519 }
520 } else {
521 path.to_path_buf()
522 };
523 let absolute = if expanded.is_absolute() {
524 expanded
525 } else if let Some(current_dir) = current_dir {
526 current_dir.join(expanded)
527 } else {
528 expanded
529 };
530 normalize_absolute_path(&absolute)
531}
532
533fn normalize_absolute_path(path: &std::path::Path) -> PathBuf {
534 use std::path::Component;
535
536 let mut normalized = PathBuf::new();
537 for component in path.components() {
538 match component {
539 Component::CurDir => {}
540 Component::ParentDir => {
541 if !normalized.pop() {
542 normalized.push(component.as_os_str());
543 }
544 }
545 other => normalized.push(other.as_os_str()),
546 }
547 }
548 normalized
549}
550
551pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
552 let root_tasks = storage_root.join("bash-tasks");
553 if !dir_has_entries(&root_tasks) {
554 return;
555 }
556
557 let harness_tasks = storage_root
558 .join(harness.storage_segment())
559 .join("bash-tasks");
560 if dir_has_entries(&harness_tasks) {
561 return;
562 }
563 if let Some(parent) = harness_tasks.parent() {
564 if let Err(error) = std::fs::create_dir_all(parent) {
565 crate::slog_warn!(
566 "failed to create harness bash task dir {}: {}",
567 parent.display(),
568 error
569 );
570 return;
571 }
572 }
573 if harness_tasks.exists() {
574 let _ = std::fs::remove_dir(&harness_tasks);
575 }
576
577 match std::fs::rename(&root_tasks, &harness_tasks) {
578 Ok(()) => crate::slog_info!(
579 "moved legacy root bash tasks into harness namespace: {}",
580 harness_tasks.display()
581 ),
582 Err(error) => {
583 crate::slog_warn!(
584 "failed to move legacy root bash tasks into {}: {}; trying child merge",
585 harness_tasks.display(),
586 error
587 );
588 if std::fs::create_dir_all(&harness_tasks).is_err() {
589 return;
590 }
591 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
592 for entry in entries.flatten() {
593 let source = entry.path();
594 let target = harness_tasks.join(entry.file_name());
595 if !target.exists() {
596 let _ = std::fs::rename(source, target);
597 }
598 }
599 }
600 let _ = std::fs::remove_dir(&root_tasks);
601 }
602 }
603}
604
605fn dir_has_entries(path: &std::path::Path) -> bool {
606 std::fs::read_dir(path)
607 .map(|mut entries| entries.next().is_some())
608 .unwrap_or(false)
609}
610
611#[cfg(test)]
612mod storage_root_tests {
613 use std::collections::HashMap;
614 use std::ffi::OsString;
615 use std::panic::{catch_unwind, AssertUnwindSafe};
616 use std::path::{Path, PathBuf};
617
618 struct NonPanickingCleanup<F: FnOnce()> {
619 cleanup: Option<F>,
620 }
621
622 impl<F: FnOnce()> NonPanickingCleanup<F> {
623 fn new(cleanup: F) -> Self {
624 Self {
625 cleanup: Some(cleanup),
626 }
627 }
628 }
629
630 impl<F: FnOnce()> Drop for NonPanickingCleanup<F> {
631 fn drop(&mut self) {
632 let Some(cleanup) = self.cleanup.take() else {
633 return;
634 };
635 let _ = catch_unwind(AssertUnwindSafe(cleanup));
638 }
639 }
640
641 fn resolve_storage_fixture(
642 env: &HashMap<&str, OsString>,
643 configured: Option<&Path>,
644 platform: super::StoragePlatform,
645 fallback_home: Option<&Path>,
646 current_dir: Option<&Path>,
647 ) -> PathBuf {
648 super::storage_dir_from(
649 configured,
650 &|name| env.get(name).cloned(),
651 platform,
652 fallback_home,
653 current_dir,
654 )
655 }
656
657 #[test]
658 fn storage_ladder_matches_daemon_except_for_stable_windows_cache_class_storage() {
659 let current_dir = Path::new("/work");
660 let fallback_home = Path::new("/system-home");
661 let module_suffix = Path::new("cortexkit").join("aft");
662 let mut env = HashMap::from([
663 ("AFT_STORAGE_DIR", OsString::new()),
664 ("AFT_CACHE_DIR", OsString::new()),
665 ("XDG_DATA_HOME", OsString::new()),
666 ("APPDATA", OsString::from("/wrong-roaming-data")),
667 ("USERPROFILE", OsString::new()),
668 ("HOME", OsString::new()),
669 ("LOCALAPPDATA", OsString::new()),
670 ]);
671
672 for platform in [
673 super::StoragePlatform::Other,
674 super::StoragePlatform::Windows,
675 ] {
676 assert_eq!(
677 resolve_storage_fixture(
678 &env,
679 Some(Path::new("")),
680 platform,
681 Some(fallback_home),
682 Some(current_dir),
683 ),
684 current_dir.join(".local/share").join(&module_suffix),
685 "empty values and an empty configured root are unset"
686 );
687 }
688 assert_eq!(
689 resolve_storage_fixture(&env, None, super::StoragePlatform::Other, None, None,),
690 PathBuf::from(".local/share/cortexkit/aft"),
691 "an unavailable cwd preserves the honest relative path"
692 );
693
694 env.insert("HOME", OsString::from("/home/operator"));
695 env.insert("USERPROFILE", OsString::from("/wrong-profile"));
696 assert_eq!(
697 resolve_storage_fixture(
698 &env,
699 None,
700 super::StoragePlatform::Other,
701 Some(fallback_home),
702 Some(current_dir),
703 ),
704 Path::new("/home/operator/.local/share").join(&module_suffix)
705 );
706
707 env.insert("LOCALAPPDATA", OsString::from("/local-data"));
708 assert_eq!(
709 resolve_storage_fixture(
710 &env,
711 None,
712 super::StoragePlatform::Windows,
713 Some(fallback_home),
714 Some(current_dir),
715 ),
716 Path::new("/local-data").join(&module_suffix)
717 );
718 env.insert("LOCALAPPDATA", OsString::new());
719 assert_eq!(
720 resolve_storage_fixture(
721 &env,
722 None,
723 super::StoragePlatform::Windows,
724 Some(fallback_home),
725 Some(current_dir),
726 ),
727 Path::new("/wrong-profile/AppData/Local").join(&module_suffix)
728 );
729
730 env.insert("XDG_DATA_HOME", OsString::from("relative-data"));
731 assert_eq!(
732 resolve_storage_fixture(
733 &env,
734 None,
735 super::StoragePlatform::Other,
736 Some(fallback_home),
737 Some(current_dir),
738 ),
739 current_dir.join("relative-data").join(&module_suffix)
740 );
741
742 env.insert("AFT_CACHE_DIR", OsString::from("/legacy-cache"));
743 assert_eq!(
744 resolve_storage_fixture(
745 &env,
746 None,
747 super::StoragePlatform::Other,
748 Some(fallback_home),
749 Some(current_dir),
750 ),
751 PathBuf::from("/legacy-cache/aft")
752 );
753 let configured = Path::new("configured/../configured-aft");
754 assert_eq!(
755 resolve_storage_fixture(
756 &env,
757 Some(configured),
758 super::StoragePlatform::Other,
759 Some(fallback_home),
760 Some(current_dir),
761 ),
762 configured,
763 "the caller-owned configured spelling outranks the legacy cache lever"
764 );
765
766 env.insert("AFT_STORAGE_DIR", OsString::from("~/operator-aft"));
767 assert_eq!(
768 resolve_storage_fixture(
769 &env,
770 Some(configured),
771 super::StoragePlatform::Other,
772 Some(fallback_home),
773 Some(current_dir),
774 ),
775 PathBuf::from("/home/operator/operator-aft")
776 );
777 }
778
779 #[test]
780 fn powershell_absence_has_an_honest_install_remedy() {
781 let error = super::resolve_powershell_path_with(|_| None).expect_err("pwsh is absent");
782 assert!(error.contains("PowerShell (pwsh) is not installed"));
783 assert!(error.contains("https://aka.ms/powershell"));
784 }
785
786 #[test]
787 fn cleanup_panic_during_unwind_does_not_abort_libtest() {
788 use std::sync::atomic::{AtomicBool, Ordering};
789
790 let cleanup_ran = AtomicBool::new(false);
791 let unwind = catch_unwind(AssertUnwindSafe(|| {
792 let _cleanup = NonPanickingCleanup::new(|| {
793 cleanup_ran.store(true, Ordering::SeqCst);
794 panic!("forced cleanup failure");
795 });
796 panic!("primary test failure");
797 }));
798
799 assert!(cleanup_ran.load(Ordering::SeqCst));
800 assert_eq!(
801 unwind
802 .expect_err("primary panic must escape the inner scope")
803 .downcast_ref::<&str>(),
804 Some(&"primary test failure")
805 );
806 }
807}