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