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;
18use crate::sandbox_spawn::{
19 current_authenticated_principal, native_sandbox_enforced, resolve_sandbox_spawn,
20 HostEscalationAttempt, RequestedSandboxTier, SandboxTaskKind,
21};
22use persistence::BgMode;
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::time::Duration;
28
29pub use registry::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
30
31#[cfg(unix)]
32pub(crate) fn resolved_shell_path(pty: bool) -> PathBuf {
33 if pty {
34 pty_process::resolve_posix_shell()
35 } else {
36 registry::resolve_posix_shell()
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct BgTaskInfo {
42 pub task_id: String,
43 pub status: BgTaskStatus,
44 pub command: String,
45 pub mode: BgMode,
46 pub started_at: u64,
47 pub duration_ms: Option<u64>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52pub enum BgTaskStatus {
53 Starting,
54 Running,
55 Killing,
56 Completed,
57 Failed,
58 Killed,
59 TimedOut,
60}
61
62impl BgTaskStatus {
63 pub fn is_terminal(&self) -> bool {
64 matches!(
65 self,
66 BgTaskStatus::Completed
67 | BgTaskStatus::Failed
68 | BgTaskStatus::Killed
69 | BgTaskStatus::TimedOut
70 )
71 }
72}
73
74#[allow(clippy::too_many_arguments)]
76pub fn spawn(
77 request_id: &str,
78 session_id: &str,
79 command: &str,
80 workdir: Option<PathBuf>,
81 env: Option<HashMap<String, String>>,
82 timeout_ms: Option<u64>,
83 ctx: &AppContext,
84 require_background_flag: bool,
85 notify_on_completion: bool,
86 compressed: bool,
87 pty: bool,
88 pty_rows: u16,
89 pty_cols: u16,
90 scanner_report: Vec<PermissionAsk>,
91 host_escalation: Option<HostEscalationAttempt>,
92) -> Response {
93 if require_background_flag && !ctx.config().experimental_bash_background {
94 return Response::error(
95 request_id,
96 "feature_disabled",
97 "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
98 );
99 }
100
101 let workdir = workdir.unwrap_or_else(|| {
102 ctx.config().project_root.clone().unwrap_or_else(|| {
103 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
104 })
105 });
106 let storage_dir = {
107 let config = ctx.config();
108 let root = storage_dir(config.storage_dir.as_deref());
109 config
110 .harness
111 .as_ref()
112 .map(|harness| root.join(harness.storage_segment()))
113 .unwrap_or(root)
114 };
115 let max_running = ctx.config().max_background_bash_tasks;
116 let timeout = timeout_ms.map(Duration::from_millis);
117 let project_root = ctx
118 .config()
119 .project_root
120 .clone()
121 .or_else(|| std::env::current_dir().ok())
122 .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
123
124 let env = env.unwrap_or_default();
125 let task_kind = if pty {
126 SandboxTaskKind::BashPty
127 } else if require_background_flag {
128 SandboxTaskKind::BashBackground
129 } else {
130 SandboxTaskKind::BashForeground
131 };
132 let principal = current_authenticated_principal();
133 let requested_tier = if host_escalation.is_some() {
134 RequestedSandboxTier::Host
135 } else if ctx.config().sandbox.enabled {
136 RequestedSandboxTier::Native
137 } else {
138 RequestedSandboxTier::Disabled
139 };
140 let task_bundle_dir = persistence::session_tasks_dir(&storage_dir, session_id);
141 if native_sandbox_enforced(ctx, &principal) {
142 if let Err(error) = std::fs::create_dir_all(&task_bundle_dir) {
143 return Response::error(
144 request_id,
145 "sandbox_unavailable",
146 format!(
147 "native sandbox failed to create the task artifact directory: {error}; set sandbox.enabled=false to disable native sandboxing"
148 ),
149 );
150 }
151 }
152 let spawn_plan = resolve_sandbox_spawn(
153 ctx,
154 &principal,
155 requested_tier,
156 task_kind,
157 &task_bundle_dir,
158 host_escalation.as_ref(),
159 );
160 if let Some(code) = spawn_plan.refusal_code() {
161 let message = spawn_plan
162 .refusal_message()
163 .unwrap_or("bash process creation refused by sandbox policy");
164 return match spawn_plan.refusal_mismatch_class() {
165 Some(class) => Response::error_with_data(
166 request_id,
167 code,
168 message,
169 json!({ "mismatch_class": class }),
170 ),
171 None => Response::error(request_id, code, message),
172 };
173 }
174
175 let cleanup_plan = spawn_plan.clone();
176 let spawn_result = if pty {
177 ctx.bash_background().spawn_pty(
178 spawn_plan,
179 command,
180 session_id.to_string(),
181 workdir,
182 env,
183 timeout,
184 storage_dir,
185 max_running,
186 notify_on_completion,
187 compressed,
188 project_root,
189 pty_rows,
190 pty_cols,
191 )
192 } else {
193 ctx.bash_background().spawn(
194 spawn_plan,
195 command,
196 session_id.to_string(),
197 workdir,
198 env,
199 timeout,
200 storage_dir,
201 max_running,
202 notify_on_completion,
203 compressed,
204 project_root,
205 )
206 };
207
208 match spawn_result {
209 Ok(task_id) => {
210 if let Err(error) =
211 ctx.bash_background()
212 .record_scanner_report(&task_id, session_id, scanner_report)
213 {
214 crate::slog_warn!("{error}");
215 }
216 Response::success(
217 request_id,
218 json!({
219 "task_id": task_id,
220 "status": BgTaskStatus::Running,
221 "mode": if pty { "pty" } else { "pipes" },
222 }),
223 )
224 }
225 Err(message) if message.contains("limit exceeded") => {
226 cleanup_plan.cleanup_unspawned();
227 Response::error(request_id, "background_task_limit_exceeded", message)
228 }
229 Err(message) => {
230 cleanup_plan.cleanup_unspawned();
231 if cleanup_plan.is_native_launcher() {
232 Response::error(
233 request_id,
234 "sandbox_unavailable",
235 format!(
236 "native sandbox failed before command execution: {message}; set sandbox.enabled=false to disable native sandboxing"
237 ),
238 )
239 } else {
240 Response::error(request_id, "execution_failed", message)
241 }
242 }
243 }
244}
245
246pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
247 if let Some(dir) = configured {
248 return dir.to_path_buf();
249 }
250 if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
251 return PathBuf::from(dir).join("aft");
252 }
253 cortexkit_data_root().join("cortexkit").join("aft")
262}
263
264fn cortexkit_data_root() -> PathBuf {
265 if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
266 if !dir.is_empty() {
267 return PathBuf::from(dir);
268 }
269 }
270 let home = std::env::var_os("HOME")
271 .or_else(|| std::env::var_os("USERPROFILE"))
272 .map(PathBuf::from)
273 .unwrap_or_else(std::env::temp_dir);
274 if cfg!(windows) {
275 return std::env::var_os("LOCALAPPDATA")
276 .or_else(|| std::env::var_os("APPDATA"))
277 .map(PathBuf::from)
278 .unwrap_or_else(|| home.join("AppData").join("Local"));
279 }
280 home.join(".local").join("share")
281}
282
283pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
284 let root_tasks = storage_root.join("bash-tasks");
285 if !dir_has_entries(&root_tasks) {
286 return;
287 }
288
289 let harness_tasks = storage_root
290 .join(harness.storage_segment())
291 .join("bash-tasks");
292 if dir_has_entries(&harness_tasks) {
293 return;
294 }
295 if let Some(parent) = harness_tasks.parent() {
296 if let Err(error) = std::fs::create_dir_all(parent) {
297 crate::slog_warn!(
298 "failed to create harness bash task dir {}: {}",
299 parent.display(),
300 error
301 );
302 return;
303 }
304 }
305 if harness_tasks.exists() {
306 let _ = std::fs::remove_dir(&harness_tasks);
307 }
308
309 match std::fs::rename(&root_tasks, &harness_tasks) {
310 Ok(()) => crate::slog_info!(
311 "moved legacy root bash tasks into harness namespace: {}",
312 harness_tasks.display()
313 ),
314 Err(error) => {
315 crate::slog_warn!(
316 "failed to move legacy root bash tasks into {}: {}; trying child merge",
317 harness_tasks.display(),
318 error
319 );
320 if std::fs::create_dir_all(&harness_tasks).is_err() {
321 return;
322 }
323 if let Ok(entries) = std::fs::read_dir(&root_tasks) {
324 for entry in entries.flatten() {
325 let source = entry.path();
326 let target = harness_tasks.join(entry.file_name());
327 if !target.exists() {
328 let _ = std::fs::rename(source, target);
329 }
330 }
331 }
332 let _ = std::fs::remove_dir(&root_tasks);
333 }
334 }
335}
336
337fn dir_has_entries(path: &std::path::Path) -> bool {
338 std::fs::read_dir(path)
339 .map(|mut entries| entries.next().is_some())
340 .unwrap_or(false)
341}
342
343#[cfg(test)]
344mod storage_root_tests {
345 use std::path::PathBuf;
346
347 #[test]
357 fn plugin_less_fallback_matches_plugin_injected_cortexkit_root() {
358 let _guard = crate::test_env::process_env_lock();
359 let data_home = std::env::var_os("XDG_DATA_HOME")
360 .filter(|value| !value.is_empty())
361 .map(PathBuf::from)
362 .unwrap_or_else(|| {
363 let home = std::env::var_os("HOME")
364 .or_else(|| std::env::var_os("USERPROFILE"))
365 .map(PathBuf::from)
366 .expect("test environment provides a home directory");
367 if cfg!(windows) {
368 std::env::var_os("LOCALAPPDATA")
369 .or_else(|| std::env::var_os("APPDATA"))
370 .map(PathBuf::from)
371 .unwrap_or_else(|| home.join("AppData").join("Local"))
372 } else {
373 home.join(".local").join("share")
374 }
375 });
376 let expected_plugin_injected_root = data_home.join("cortexkit").join("aft");
377
378 let cache_dir_override_absent = std::env::var_os("AFT_CACHE_DIR").is_none();
379 assert!(
380 cache_dir_override_absent,
381 "test requires AFT_CACHE_DIR unset to exercise the real fallback"
382 );
383
384 assert_eq!(
385 super::storage_dir(None),
386 expected_plugin_injected_root,
387 "bash_background::storage_dir fallback diverged from the plugin-injected root"
388 );
389
390 let temp_project = tempfile::tempdir().expect("temp project");
391 let resolved = crate::search_index::resolve_cache_dir(temp_project.path(), None);
392 assert!(
393 resolved.starts_with(&expected_plugin_injected_root),
394 "search_index::resolve_cache_dir fallback diverged: {}",
395 resolved.display()
396 );
397 }
398}