1#![allow(clippy::not_unsafe_ptr_arg_deref)]
30
31use crate::config::AgentConfig;
32use crate::runtime::{AgentRuntime, TOKIO_RT};
33use crate::session::{valid_session_id, SessionManager, SessionMessage, SCHEMA_VERSION};
34use crate::stream::CallbackSink;
35use serde_json::json;
36use std::ffi::{c_char, c_void, CStr, CString};
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::{Arc, Mutex, OnceLock};
39use std::sync::mpsc;
40
41pub type StreamCallback = extern "C" fn(*const c_char, *mut c_void);
45
46pub struct AacodeTask {
48 cancel: Arc<AtomicBool>,
49 result_rx: mpsc::Receiver<String>,
50}
51
52struct SendUserData(*mut c_void);
55unsafe impl Send for SendUserData {}
59unsafe impl Sync for SendUserData {}
60
61impl SendUserData {
62 #[inline]
65 fn ptr(&self) -> *mut c_void {
66 self.0
67 }
68}
69
70static RUNNING_SESSIONS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
74
75fn running_sessions() -> &'static Mutex<std::collections::HashSet<String>> {
76 RUNNING_SESSIONS.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
77}
78
79struct SessionGuard {
82 key: String,
83}
84
85impl SessionGuard {
86 fn try_acquire(key: String) -> Option<Self> {
87 let mut set = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
88 if set.contains(&key) {
89 return None;
90 }
91 set.insert(key.clone());
92 Some(SessionGuard { key })
93 }
94}
95
96impl Drop for SessionGuard {
97 fn drop(&mut self) {
98 let mut set = running_sessions().lock().unwrap_or_else(|e| e.into_inner());
99 set.remove(&self.key);
100 }
101}
102
103unsafe fn cstr<'a>(ptr: *const c_char) -> Option<&'a str> {
105 if ptr.is_null() {
106 return None;
107 }
108 CStr::from_ptr(ptr).to_str().ok()
109}
110
111fn to_c_string(s: String) -> *mut c_char {
113 CString::new(s)
114 .unwrap_or_else(|_| CString::new("").unwrap())
115 .into_raw()
116}
117
118fn resolve_shell_root(
142 sandbox: &str,
143 project: &str,
144 user_dir: Option<&str>,
145) -> (String, Option<String>) {
146 let Some(user_dir) = user_dir else {
147 return (project.to_string(), None);
148 };
149 let canon = |s: &str| {
150 std::fs::canonicalize(s).unwrap_or_else(|_| std::path::PathBuf::from(s))
151 };
152 let sandbox_c = canon(sandbox);
153 let project_c = canon(project);
154 let user_dir_c = canon(user_dir);
155
156 match project_c.strip_prefix(&sandbox_c) {
158 Ok(rel) if !rel.as_os_str().is_empty() => {
159 return (sandbox.to_string(), Some(rel.to_string_lossy().to_string()));
160 }
161 _ => {}
162 }
163
164 if user_dir_c.strip_prefix(&project_c).is_ok() {
166 return (project.to_string(), None);
167 }
168
169 for ancestor in project_c.ancestors().skip(1) {
177 if ancestor.parent().is_none() {
178 break;
179 }
180 if user_dir_c.strip_prefix(ancestor).is_ok() {
181 if let Ok(rel) = project_c.strip_prefix(ancestor) {
182 if !rel.as_os_str().is_empty() {
183 return (ancestor.to_string_lossy().to_string(), Some(rel.to_string_lossy().to_string()));
184 }
185 }
186 }
187 }
188
189 (project.to_string(), None)
190}
191
192fn build_runtime(
195 config: &AgentConfig,
196 project_path: &str,
197) -> Result<AgentRuntime, String> {
198 if let Some(sdk) = fastshell::sdk::try_get_sdk_instance() {
199 let sandbox = {
200 let guard = sdk.lock().unwrap_or_else(|e| e.into_inner());
201 guard.vfs_root()
202 };
203 let pp = if project_path == "." { sandbox.clone() } else { project_path.to_string() };
204 let (shell_root, cd_rel) =
206 resolve_shell_root(&sandbox, &pp, config.skills.user_dir.as_deref());
207 let mut cfg = config.clone();
211 if let Some(ref user_dir) = cfg.skills.user_dir {
212 if let Ok(rel) = std::path::Path::new(user_dir).strip_prefix(&shell_root) {
213 if !rel.as_os_str().is_empty() {
214 cfg.skills.vfs_skills_dir = Some(format!("/{}", rel.to_string_lossy()));
215 }
216 }
217 }
218 let fs_arc: Arc<Mutex<fastshell::Fastshell>> =
219 Arc::new(Mutex::new(fastshell::Fastshell::new()));
220 {
221 let mut ours = fs_arc.lock().unwrap();
222 let mut fscfg = fastshell::Config::default();
223 fscfg.sandbox_path = shell_root.clone();
224 fscfg.python_enabled = true;
225 fscfg.allow_subprocess = false; fscfg.network_ask_permission = false;
227 if let Err(e) = ours.init(fscfg) {
228 return Err(e.to_string());
229 }
230 if let Some(rel) = cd_rel {
231 let r = ours.execute(&format!("cd '{}'", rel.replace('\'', "'\\''")));
234 if r.exit_code != 0 {
235 return Err(format!("cd to project failed: {}", r.stderr));
236 }
237 }
238 }
239 Ok(AgentRuntime::with_fastshell(cfg, std::path::PathBuf::from(pp), fs_arc))
240 } else {
241 AgentRuntime::init(config.clone(), std::path::PathBuf::from(project_path))
242 .map_err(|e| e.to_string())
243 }
244}
245
246#[no_mangle]
249pub extern "C" fn aacode_task_start(
250 task_json: *const c_char,
251 cb: Option<StreamCallback>,
252 userdata: *mut c_void,
253) -> *mut AacodeTask {
254 let (tx, rx) = mpsc::channel::<String>();
256 let cancel = Arc::new(AtomicBool::new(false));
257
258 let ud = SendUserData(userdata);
259 let emit = move |line: &str| {
260 if let Some(cb) = cb {
261 if let Ok(cs) = CString::new(line) {
262 cb(cs.as_ptr(), ud.ptr());
263 }
264 }
265 };
266
267 let input = match unsafe { cstr(task_json) } {
268 Some(s) => s.to_string(),
269 None => {
270 let msg = "null task_json";
271 emit(&json!({"type": "error", "message": msg}).to_string());
272 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
273 return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
274 }
275 };
276
277 let v: serde_json::Value = match serde_json::from_str(&input) {
278 Ok(v) => v,
279 Err(e) => {
280 let msg = format!("bad task_json: {e}");
281 emit(&json!({"type": "error", "message": msg}).to_string());
282 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
283 return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
284 }
285 };
286 let task = v.get("task").and_then(|x| x.as_str()).unwrap_or("").to_string();
287 if task.is_empty() {
288 let msg = "missing task";
289 emit(&json!({"type": "error", "message": msg}).to_string());
290 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
291 return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
292 }
293 let project_path = v.get("project_path").and_then(|x| x.as_str()).unwrap_or(".").to_string();
294 let session_id = v.get("session_id").and_then(|x| x.as_str()).map(|s| s.to_string());
295
296 let session_guard = match &session_id {
299 Some(sid) => {
300 let key = format!("{project_path}::{sid}");
301 match SessionGuard::try_acquire(key) {
302 Some(g) => Some(g),
303 None => {
304 let msg = format!("session '{sid}' already has a task running");
305 emit(&json!({"type": "error", "message": msg}).to_string());
306 let _ = tx.send(json!({"status": "error", "error": msg}).to_string());
307 return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
308 }
309 }
310 }
311 None => None,
312 };
313
314 let mut config: AgentConfig = serde_json::from_value(v).unwrap_or_default();
315 config.apply_env();
316
317 let rt = match build_runtime(&config, &project_path) {
318 Ok(rt) => rt,
319 Err(e) => {
320 emit(&json!({"type": "error", "message": e}).to_string());
321 let _ = tx.send(json!({"status": "error", "error": e}).to_string());
322 return Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }));
323 }
324 };
325
326 let cancel2 = cancel.clone();
327 let _join = TOKIO_RT.spawn(async move {
328 let _guard = session_guard;
330 let result = rt
331 .run_task(&task, session_id.as_deref(), &CallbackSink::new(Box::new(emit)), &cancel2)
332 .await;
333 let json = match result {
334 Ok(res) => res.to_result_json().to_string(),
335 Err(e) => json!({"status": "error", "error": e.to_string()}).to_string(),
336 };
337 let _ = tx.send(json);
338 });
339
340 Box::into_raw(Box::new(AacodeTask { cancel, result_rx: rx }))
341}
342
343#[no_mangle]
346pub extern "C" fn aacode_task_wait(handle: *mut AacodeTask) -> *mut c_char {
347 if handle.is_null() {
349 return to_c_string(json!({"status": "error", "error": "null handle"}).to_string());
350 }
351 let h = unsafe { &*handle };
352 let result = h.result_rx.recv().unwrap_or_else(|_| {
353 json!({"status": "error", "error": "task was dropped before completion"}).to_string()
354 });
355 to_c_string(result)
356}
357
358#[no_mangle]
360pub extern "C" fn aacode_task_cancel(handle: *mut AacodeTask) {
361 if handle.is_null() {
363 return;
364 }
365 let h = unsafe { &*handle };
366 h.cancel.store(true, Ordering::SeqCst);
367}
368
369#[no_mangle]
371pub extern "C" fn aacode_task_free(handle: *mut AacodeTask) {
372 if handle.is_null() {
374 return;
375 }
376 unsafe {
377 drop(Box::from_raw(handle));
378 }
379}
380
381#[no_mangle]
384pub extern "C" fn aacode_validate_api_key(config_json: *const c_char) -> *mut c_char {
385 let input = match unsafe { cstr(config_json) } {
387 Some(s) => s.to_string(),
388 None => return to_c_string(json!({"valid": false, "error": "null config"}).to_string()),
389 };
390 let mut config: AgentConfig = serde_json::from_str(&input).unwrap_or_default();
391 config.apply_env();
392 let client = crate::llm::build_client(&config.model);
393 let handle = TOKIO_RT.handle().clone();
394 let out = std::thread::scope(|s| {
395 s.spawn(move || match handle.block_on(async { client.validate().await }) {
396 Ok(()) => json!({"valid": true}),
397 Err(e) => json!({"valid": false, "error": e.to_string()}),
398 })
399 .join()
400 .unwrap()
401 });
402 to_c_string(out.to_string())
403}
404
405#[no_mangle]
407pub extern "C" fn aacode_list_sessions(project_path: *const c_char) -> *mut c_char {
408 let pp = match unsafe { cstr(project_path) } {
410 Some(s) => s.to_string(),
411 None => return to_c_string(json!({"success": false, "error": "null path"}).to_string()),
412 };
413 let sm = SessionManager::new(std::path::Path::new(&pp));
414 let sessions: Vec<serde_json::Value> = sm
415 .list_sessions()
416 .into_iter()
417 .map(|s| {
418 json!({
419 "session_id": s.session_id,
420 "title": s.title,
421 "created_at": s.created_at,
422 "last_activity": s.last_activity,
423 "total_messages": s.total_messages,
424 "status": s.status,
425 })
426 })
427 .collect();
428 to_c_string(json!({"success": true, "sessions": sessions}).to_string())
429}
430
431#[no_mangle]
433pub extern "C" fn aacode_get_session_messages(
434 project_path: *const c_char,
435 session_id: *const c_char,
436) -> *mut c_char {
437 let pp = unsafe { cstr(project_path) }.unwrap_or("").to_string();
439 let sid = unsafe { cstr(session_id) }.unwrap_or("").to_string();
440 let sm = SessionManager::new(std::path::Path::new(&pp));
441 let msgs = sm.read_session_messages(&sid);
442 let arr: Vec<serde_json::Value> = msgs
443 .iter()
444 .map(|m| {
445 json!({
446 "role": m.role,
447 "content": m.content,
448 "timestamp": m.timestamp,
449 "tool_calls": m.tool_calls,
450 "tool_call_id": m.tool_call_id,
451 "reasoning_content": m.reasoning_content,
452 "segments": m.segments,
453 })
454 })
455 .collect();
456 to_c_string(json!({"success": true, "messages": arr}).to_string())
457}
458
459static SESSION_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
465
466fn message_to_json(m: &SessionMessage) -> serde_json::Value {
468 json!({
469 "role": m.role,
470 "content": m.content,
471 "timestamp": m.timestamp,
472 "tokens": m.tokens,
473 "tool_calls": m.tool_calls,
474 "tool_call_id": m.tool_call_id,
475 "reasoning_content": m.reasoning_content,
476 "segments": m.segments,
477 })
478}
479
480fn session_err(msg: &str) -> *mut c_char {
482 to_c_string(json!({"success": false, "error": msg}).to_string())
483}
484
485fn session_id_args(
488 project_path: *const c_char,
489 session_id: *const c_char,
490) -> Result<(String, String), String> {
491 let pp = unsafe { cstr(project_path) }
492 .ok_or_else(|| "null project_path".to_string())?
493 .to_string();
494 let sid = unsafe { cstr(session_id) }
495 .ok_or_else(|| "null session_id".to_string())?
496 .to_string();
497 if !valid_session_id(&sid) {
498 return Err("invalid session_id".to_string());
499 }
500 Ok((pp, sid))
501}
502
503#[no_mangle]
505pub extern "C" fn aacode_session_version() -> u32 {
506 SCHEMA_VERSION
507}
508
509#[no_mangle]
511pub extern "C" fn aacode_session_list(project_path: *const c_char) -> *mut c_char {
512 let pp = match unsafe { cstr(project_path) } {
513 Some(s) => s.to_string(),
514 None => return session_err("null project_path"),
515 };
516 let sm = SessionManager::new(std::path::Path::new(&pp));
517 let sessions: Vec<serde_json::Value> = sm
518 .list_sessions()
519 .into_iter()
520 .map(|s| {
521 json!({
522 "session_id": s.session_id,
523 "title": s.title,
524 "created_at": s.created_at,
525 "last_activity": s.last_activity,
526 "total_messages": s.total_messages,
527 "total_tokens": s.total_tokens,
528 "status": s.status,
529 })
530 })
531 .collect();
532 to_c_string(json!({"success": true, "sessions": sessions}).to_string())
533}
534
535#[no_mangle]
537pub extern "C" fn aacode_session_messages(
538 project_path: *const c_char,
539 session_id: *const c_char,
540 offset: u32,
541 limit: u32,
542) -> *mut c_char {
543 let (pp, sid) = match session_id_args(project_path, session_id) {
544 Ok(v) => v,
545 Err(e) => return session_err(&e),
546 };
547 let sm = SessionManager::new(std::path::Path::new(&pp));
548 let all = sm.read_session_messages(&sid);
549 let total = all.len();
550 let offset = offset as usize;
551 let limit = limit as usize;
552 let end = total.saturating_sub(offset);
553 let start = end.saturating_sub(limit);
554 let slice: Vec<serde_json::Value> = if start < end {
555 all[start..end].iter().map(message_to_json).collect()
556 } else {
557 Vec::new()
558 };
559 to_c_string(
560 json!({
561 "success": true,
562 "total": total,
563 "offset": offset,
564 "limit": limit,
565 "messages": slice,
566 })
567 .to_string(),
568 )
569}
570
571#[no_mangle]
573pub extern "C" fn aacode_session_ensure(
574 project_path: *const c_char,
575 session_id: *const c_char,
576 title: *const c_char,
577) -> *mut c_char {
578 let (pp, sid) = match session_id_args(project_path, session_id) {
579 Ok(v) => v,
580 Err(e) => return session_err(&e),
581 };
582 let title = unsafe { cstr(title) }.unwrap_or("").to_string();
583 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
584 let mut sm = SessionManager::new(std::path::Path::new(&pp));
585 match sm.ensure_session(&sid, &title) {
586 Ok(()) => to_c_string(json!({"success": true}).to_string()),
587 Err(e) => session_err(&e.to_string()),
588 }
589}
590
591#[no_mangle]
593pub extern "C" fn aacode_session_rename(
594 project_path: *const c_char,
595 session_id: *const c_char,
596 title: *const c_char,
597) -> *mut c_char {
598 let (pp, sid) = match session_id_args(project_path, session_id) {
599 Ok(v) => v,
600 Err(e) => return session_err(&e),
601 };
602 let title = unsafe { cstr(title) }.unwrap_or("").to_string();
603 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
604 let mut sm = SessionManager::new(std::path::Path::new(&pp));
605 match sm.rename_session(&sid, &title) {
606 Ok(()) => to_c_string(json!({"success": true}).to_string()),
607 Err(e) => session_err(&e.to_string()),
608 }
609}
610
611#[no_mangle]
613pub extern "C" fn aacode_session_touch(
614 project_path: *const c_char,
615 session_id: *const c_char,
616) -> *mut c_char {
617 let (pp, sid) = match session_id_args(project_path, session_id) {
618 Ok(v) => v,
619 Err(e) => return session_err(&e),
620 };
621 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
622 let mut sm = SessionManager::new(std::path::Path::new(&pp));
623 match sm.touch_session(&sid) {
624 Ok(()) => to_c_string(json!({"success": true}).to_string()),
625 Err(e) => session_err(&e.to_string()),
626 }
627}
628
629#[no_mangle]
631pub extern "C" fn aacode_session_delete(
632 project_path: *const c_char,
633 session_id: *const c_char,
634) -> *mut c_char {
635 let (pp, sid) = match session_id_args(project_path, session_id) {
636 Ok(v) => v,
637 Err(e) => return session_err(&e),
638 };
639 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
640 let mut sm = SessionManager::new(std::path::Path::new(&pp));
641 match sm.delete_session(&sid) {
642 Ok(_) => to_c_string(json!({"success": true}).to_string()),
643 Err(e) => session_err(&e.to_string()),
644 }
645}
646
647#[no_mangle]
649pub extern "C" fn aacode_session_append(
650 project_path: *const c_char,
651 session_id: *const c_char,
652 msgs_json: *const c_char,
653) -> *mut c_char {
654 let (pp, sid) = match session_id_args(project_path, session_id) {
655 Ok(v) => v,
656 Err(e) => return session_err(&e),
657 };
658 let msgs_str = match unsafe { cstr(msgs_json) } {
659 Some(s) => s,
660 None => return session_err("null msgs_json"),
661 };
662 let msgs: Vec<SessionMessage> = match serde_json::from_str(msgs_str) {
663 Ok(v) => v,
664 Err(e) => return session_err(&format!("bad msgs_json: {e}")),
665 };
666 let _guard = SESSION_WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
667 let mut sm = SessionManager::new(std::path::Path::new(&pp));
668 match sm.append_session_messages(&sid, msgs) {
669 Ok(()) => to_c_string(json!({"success": true}).to_string()),
670 Err(e) => session_err(&e.to_string()),
671 }
672}
673
674#[no_mangle]
676pub extern "C" fn aacode_free_string(ptr: *mut c_char) {
677 if ptr.is_null() {
679 return;
680 }
681 unsafe {
682 let _ = CString::from_raw(ptr);
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 extern "C" fn capture_cb(line: *const c_char, userdata: *mut c_void) {
691 if line.is_null() {
692 return;
693 }
694 let l = unsafe { CStr::from_ptr(line).to_str().unwrap_or("").to_string() };
695 let sink = userdata as *mut Mutex<Vec<String>>;
696 if !sink.is_null() {
697 unsafe {
698 if let Ok(mut v) = (*sink).lock() {
699 v.push(l);
700 }
701 }
702 }
703 }
704
705 fn run_task(json: &str) -> (String, Vec<String>) {
706 let c = CString::new(json).unwrap();
707 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
708 let ud = &lines as *const _ as *mut c_void;
709 let h = aacode_task_start(c.as_ptr(), Some(capture_cb), ud);
710 let result = unsafe {
711 let p = aacode_task_wait(h);
712 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
713 aacode_free_string(p);
714 aacode_task_free(h);
715 s
716 };
717 let captured = lines.lock().unwrap().clone();
718 (result, captured)
719 }
720
721 #[test]
722 fn run_task_bad_json() {
723 let (out, events) = run_task("not json");
724 assert!(out.contains("bad task_json"), "got: {out}");
725 assert!(
726 events.iter().any(|l| l.contains(r#""type":"error""#) && l.contains("bad task_json")),
727 "error event must be emitted: {events:?}"
728 );
729 }
730
731 #[test]
732 fn run_task_missing_task() {
733 let (out, events) = run_task(r#"{"project_path":"/tmp"}"#);
734 assert!(out.contains("error"), "got: {out}");
735 assert!(events.iter().any(|l| l.contains(r#""type":"error""#)));
736 }
737
738 #[test]
739 fn callback_receives_userdata_on_early_error() {
740 use std::sync::atomic::AtomicUsize;
741 static RECEIVED_UD: AtomicUsize = AtomicUsize::new(0);
742 extern "C" fn ud_cb(_line: *const c_char, ud: *mut c_void) {
743 RECEIVED_UD.store(ud as usize, Ordering::SeqCst);
744 }
745 let c = CString::new(r#"{"project_path":"/tmp"}"#).unwrap();
746 let marker = 0xABCD1234usize as *mut c_void;
747 let h = aacode_task_start(c.as_ptr(), Some(ud_cb), marker);
748 let p = aacode_task_wait(h);
749 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
750 aacode_free_string(p);
751 aacode_task_free(h);
752 assert!(s.contains("missing task"), "got: {s}");
753 assert_eq!(RECEIVED_UD.load(Ordering::SeqCst), 0xABCD1234usize);
755 }
756
757 #[test]
758 fn start_with_null_callback_still_returns_terminal_result() {
759 let c = CString::new("not json").unwrap();
760 let h = aacode_task_start(c.as_ptr(), None, std::ptr::null_mut());
761 let p = aacode_task_wait(h);
762 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
763 aacode_free_string(p);
764 aacode_task_free(h);
765 assert!(s.contains("bad task_json"), "got: {s}");
766 }
767
768 #[test]
769 fn null_task_json() {
770 let lines: Mutex<Vec<String>> = Mutex::new(Vec::new());
771 let ud = &lines as *const _ as *mut c_void;
772 let h = aacode_task_start(std::ptr::null(), Some(capture_cb), ud);
773 let result = unsafe {
774 let p = aacode_task_wait(h);
775 let s = CStr::from_ptr(p).to_str().unwrap().to_string();
776 aacode_free_string(p);
777 aacode_task_free(h);
778 s
779 };
780 assert!(result.contains("null task_json"));
781 }
782
783 #[test]
784 fn wait_null_handle_is_safe() {
785 let p = aacode_task_wait(std::ptr::null_mut());
786 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
787 aacode_free_string(p);
788 assert!(s.contains("error"));
789 }
790
791 #[test]
792 fn cancel_null_handle_is_safe() {
793 aacode_task_cancel(std::ptr::null_mut());
794 }
795
796 #[test]
797 fn free_null_is_safe() {
798 aacode_task_free(std::ptr::null_mut());
799 aacode_free_string(std::ptr::null_mut());
800 }
801
802 #[test]
803 fn session_guard_rejects_duplicate() {
804 let g1 = SessionGuard::try_acquire("p::s1".to_string());
805 assert!(g1.is_some());
806 let g2 = SessionGuard::try_acquire("p::s1".to_string());
807 assert!(g2.is_none(), "duplicate session must be rejected");
808 drop(g1);
809 let g3 = SessionGuard::try_acquire("p::s1".to_string());
810 assert!(g3.is_some(), "released session must be reacquirable");
811 }
812
813 #[test]
814 fn concurrent_tasks_same_session_rejected() {
815 let _hold = SessionGuard::try_acquire("/tmp/proj::sess_dup".to_string()).unwrap();
816 let (out, _events) = run_task(
817 r#"{"task":"do x","project_path":"/tmp/proj","session_id":"sess_dup","model":{"name":"m","api_key":"k"}}"#,
818 );
819 assert!(out.contains("already has a task running"), "got: {out}");
820 }
821
822 #[test]
823 fn list_sessions_ffi() {
824 let d = std::env::temp_dir().join(format!("ffi_ls_{}", uuid::Uuid::new_v4().simple()));
825 std::fs::create_dir_all(&d).unwrap();
826 {
827 let mut sm = SessionManager::new(&d);
828 sm.create_session("task", None).unwrap();
829 }
830 let c = CString::new(d.to_string_lossy().to_string()).unwrap();
831 let ptr = aacode_list_sessions(c.as_ptr());
832 let s = unsafe { CStr::from_ptr(ptr).to_str().unwrap().to_string() };
833 aacode_free_string(ptr);
834 assert!(s.contains("\"success\":true"));
835 }
836
837 fn mkdirs(p: &std::path::Path) -> String {
840 std::fs::create_dir_all(p).unwrap();
841 p.to_string_lossy().to_string()
842 }
843
844 fn tmp_root(tag: &str) -> std::path::PathBuf {
845 std::env::temp_dir().join(format!("ffi_root_{tag}_{}", uuid::Uuid::new_v4().simple()))
846 }
847
848 #[test]
849 fn no_skills_config_keeps_project_jail() {
850 let base = tmp_root("nocfg");
851 let sb = mkdirs(&base.join("sb"));
852 let pp = mkdirs(&base.join("sb").join("proj"));
853 let (root, rel) = resolve_shell_root(&sb, &pp, None);
854 assert_eq!(root, pp);
855 assert!(rel.is_none());
856 }
857
858 #[test]
859 fn project_inside_sandbox_widens_and_positions_cwd() {
860 let base = tmp_root("inside");
861 let sb = mkdirs(&base.join("sb"));
862 let sk = mkdirs(&base.join("sb").join("skills"));
863 let pp = mkdirs(&base.join("sb").join("a").join("b"));
864 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
865 assert_eq!(root, sb);
866 let rel = rel.expect("must reposition cwd");
867 assert_eq!(
868 std::path::PathBuf::from(rel),
869 std::path::PathBuf::from("a").join("b")
870 );
871 }
872
873 #[test]
874 fn project_equal_to_sandbox_user_dir_inside_no_widening() {
875 let base = tmp_root("equal_in");
876 let sb = mkdirs(&base.join("sb"));
877 let sk = mkdirs(&base.join("sb").join("skills"));
878 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
879 assert_eq!(root, sb);
880 assert!(rel.is_none());
881 }
882
883 #[test]
884 fn project_equal_to_sandbox_user_dir_outside_widens() {
885 let base = tmp_root("equal_out");
886 let sb = mkdirs(&base.join("sb"));
887 let sk = mkdirs(&base.join("skills"));
888 let (root, rel) = resolve_shell_root(&sb, &sb, Some(&sk));
889 let base_c = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
890 assert_eq!(root, base_c.to_string_lossy());
891 assert_eq!(rel.as_deref(), Some("sb"));
892 }
893
894 #[test]
895 fn sibling_prefix_dir_is_not_inside_sandbox() {
896 let base = tmp_root("sibling");
897 let sb = mkdirs(&base.join("sb"));
898 let pp = mkdirs(&base.join("sb2").join("proj"));
899 let sk = mkdirs(&base.join("sb2").join("proj").join("skills"));
900 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
901 assert_eq!(root, pp, "sibling project must keep its own jail");
902 assert!(rel.is_none());
903 }
904
905 #[test]
906 fn project_outside_sandbox_keeps_own_jail() {
907 let base = tmp_root("outside");
908 let sb = mkdirs(&base.join("sb"));
909 let pp = mkdirs(&base.join("elsewhere").join("proj"));
910 let sk = mkdirs(&base.join("elsewhere").join("proj").join("skills"));
911 let (root, rel) = resolve_shell_root(&sb, &pp, Some(&sk));
912 assert_eq!(root, pp);
913 assert!(rel.is_none());
914 }
915
916 #[cfg(unix)]
917 #[test]
918 fn symlinked_project_path_still_recognized() {
919 let base = tmp_root("symlink");
920 let real_sb = mkdirs(&base.join("real_sb"));
921 mkdirs(&base.join("real_sb").join("proj"));
922 let sk = mkdirs(&base.join("real_sb").join("skills"));
923 let link_sb = base.join("link_sb");
924 std::os::unix::fs::symlink(&real_sb, &link_sb).unwrap();
925 let pp_via_link = link_sb.join("proj").to_string_lossy().to_string();
926 let (root, rel) = resolve_shell_root(&real_sb, &pp_via_link, Some(&sk));
927 assert_eq!(root, real_sb, "symlinked project must widen to sandbox");
928 assert_eq!(rel.as_deref(), Some("proj"));
929 }
930
931 fn call_str(f: impl FnOnce() -> *mut c_char) -> String {
934 let p = f();
935 let s = unsafe { CStr::from_ptr(p).to_str().unwrap().to_string() };
936 aacode_free_string(p);
937 s
938 }
939
940 #[test]
941 fn session_version_is_one() {
942 assert_eq!(aacode_session_version(), SCHEMA_VERSION);
943 assert_eq!(aacode_session_version(), 1);
944 }
945
946 #[test]
947 fn session_lifecycle_roundtrip() {
948 let proj = mkdirs(&tmp_root("sess_life"));
949 let pp = CString::new(proj).unwrap();
950 let sid = CString::new("sess_a").unwrap();
951
952 let title = CString::new("My Session").unwrap();
954 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), sid.as_ptr(), title.as_ptr()));
955 assert!(r.contains("\"success\":true"), "ensure: {r}");
956
957 let msgs = r#"[{"role":"user","content":"hello","timestamp":"1700000001"},{"role":"assistant","content":"hi","timestamp":"1700000002"}]"#;
959 let mj = CString::new(msgs).unwrap();
960 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
961 assert!(r.contains("\"success\":true"), "append: {r}");
962
963 let new_title = CString::new("Renamed").unwrap();
965 let r = call_str(|| aacode_session_rename(pp.as_ptr(), sid.as_ptr(), new_title.as_ptr()));
966 assert!(r.contains("\"success\":true"), "rename: {r}");
967
968 let r = call_str(|| aacode_session_list(pp.as_ptr()));
970 assert!(r.contains("\"success\":true"), "list: {r}");
971 assert!(r.contains("sess_a"));
972 assert!(r.contains("Renamed"));
973
974 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
976 assert!(r.contains("\"success\":true"), "messages: {r}");
977 assert!(r.contains("\"total\":2"));
978 assert!(r.contains("hello"));
979
980 let r = call_str(|| aacode_session_touch(pp.as_ptr(), sid.as_ptr()));
982 assert!(r.contains("\"success\":true"), "touch: {r}");
983
984 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
986 assert!(r.contains("\"success\":true"), "delete: {r}");
987 let r = call_str(|| aacode_session_delete(pp.as_ptr(), sid.as_ptr()));
988 assert!(r.contains("\"success\":true"), "delete again (idempotent): {r}");
989 }
990
991 #[test]
992 fn session_append_bad_json_errors() {
993 let proj = mkdirs(&tmp_root("sess_bad"));
994 let pp = CString::new(proj).unwrap();
995 let sid = CString::new("s1").unwrap();
996 let bad = CString::new("not json").unwrap();
997 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), bad.as_ptr()));
998 assert!(r.contains("\"success\":false"), "bad msgs_json must error: {r}");
999 assert!(r.contains("bad msgs_json"));
1000 }
1001
1002 #[test]
1003 fn session_messages_pagination() {
1004 let proj = mkdirs(&tmp_root("sess_page"));
1005 let pp = CString::new(proj.clone()).unwrap();
1006 let sid = CString::new("s1").unwrap();
1007 {
1008 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1009 sm.ensure_session("s1", "").unwrap();
1010 for i in 0..5 {
1011 sm.append_session_messages(
1012 "s1",
1013 vec![SessionMessage {
1014 role: "user".to_string(),
1015 content: format!("m{i}"),
1016 timestamp: format!("17000000{i:02}"),
1017 tokens: 1,
1018 tool_calls: None,
1019 tool_call_id: None,
1020 reasoning_content: None,
1021 segments: None,
1022 }],
1023 )
1024 .unwrap();
1025 }
1026 }
1027 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 2));
1029 assert!(r.contains("\"total\":5"), "total: {r}");
1030 assert!(r.contains("m4") && r.contains("m3") && !r.contains("m0"), "page0: {r}");
1031 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 2, 2));
1033 assert!(r.contains("m2") && r.contains("m1") && !r.contains("m3"), "page1: {r}");
1034 }
1035
1036 #[test]
1037 fn session_messages_null_args_error() {
1038 let r = call_str(|| aacode_session_messages(std::ptr::null(), std::ptr::null(), 0, 50));
1039 assert!(r.contains("\"success\":false"), "null args: {r}");
1040 }
1041
1042 #[test]
1043 fn session_ffi_rejects_path_traversal_sid() {
1044 let proj = mkdirs(&tmp_root("sess_trav"));
1045 let pp = CString::new(proj).unwrap();
1046 let evil = CString::new("../escape").unwrap();
1047 let r = call_str(|| aacode_session_ensure(pp.as_ptr(), evil.as_ptr(), std::ptr::null()));
1048 assert!(r.contains("\"success\":false"), "path traversal must be rejected: {r}");
1049 assert!(r.contains("invalid session_id"));
1050 let r = call_str(|| aacode_session_messages(pp.as_ptr(), evil.as_ptr(), 0, 50));
1051 assert!(r.contains("\"success\":false"));
1052 let r = call_str(|| aacode_session_delete(pp.as_ptr(), evil.as_ptr()));
1053 assert!(r.contains("\"success\":false"));
1054 }
1055
1056 #[test]
1057 fn session_messages_limit_zero_and_beyond_total() {
1058 let proj = mkdirs(&tmp_root("sess_l0"));
1059 let pp = CString::new(proj.clone()).unwrap();
1060 let sid = CString::new("s1").unwrap();
1061 {
1062 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1063 sm.ensure_session("s1", "").unwrap();
1064 sm.append_session_messages(
1065 "s1",
1066 vec![SessionMessage {
1067 role: "user".to_string(),
1068 content: "hi".to_string(),
1069 timestamp: "1".to_string(),
1070 tokens: 1,
1071 tool_calls: None,
1072 tool_call_id: None,
1073 reasoning_content: None,
1074 segments: None,
1075 }],
1076 )
1077 .unwrap();
1078 }
1079 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 0));
1081 assert!(r.contains("\"total\":1"), "limit=0 total: {r}");
1082 assert!(!r.contains("\"hi\""), "limit=0 must return no messages: {r}");
1083 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 5, 10));
1085 assert!(r.contains("\"total\":1"));
1086 assert!(!r.contains("\"hi\""), "offset beyond total must be empty: {r}");
1087 }
1088
1089 #[test]
1090 fn session_unicode_content_roundtrip() {
1091 let proj = mkdirs(&tmp_root("sess_uni"));
1092 let pp = CString::new(proj).unwrap();
1093 let sid = CString::new("s1").unwrap();
1094 let msgs = r#"[{"role":"user","content":"你好,世界 🌍","timestamp":"1700000001"}]"#;
1095 let mj = CString::new(msgs).unwrap();
1096 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1097 assert!(r.contains("\"success\":true"), "append unicode: {r}");
1098 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1099 assert!(r.contains("你好,世界"), "unicode content preserved: {r}");
1100 }
1101
1102 #[test]
1103 fn session_append_and_read_segments() {
1104 let proj = mkdirs(&tmp_root("sess_segs"));
1105 let pp = CString::new(proj).unwrap();
1106 let sid = CString::new("s1").unwrap();
1107 let msgs = r#"[
1108 {"role":"assistant","content":"ok","timestamp":"1700000001",
1109 "segments":[
1110 {"type":"thinking","content":"think","created_at":"1700000001.001"},
1111 {"type":"action","name":"run_shell","content":"{}","created_at":"1700000001.002"}
1112 ]}
1113 ]"#;
1114 let mj = CString::new(msgs).unwrap();
1115 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1116 assert!(r.contains("\"success\":true"), "append segments: {r}");
1117
1118 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1119 assert!(r.contains("\"type\":\"thinking\""), "thinking segment round-trips: {r}");
1120 assert!(r.contains("\"name\":\"run_shell\""), "action name round-trips: {r}");
1121 assert!(r.contains("\"created_at\":\"1700000001.001\""), "created_at round-trips: {r}");
1122 }
1123
1124 #[test]
1125 fn session_append_without_segments_still_works() {
1126 let proj = mkdirs(&tmp_root("sess_noseg"));
1127 let pp = CString::new(proj).unwrap();
1128 let sid = CString::new("s1").unwrap();
1129 let msgs = r#"[{"role":"user","content":"hi","timestamp":"1700000001"}]"#;
1130 let mj = CString::new(msgs).unwrap();
1131 let r = call_str(|| aacode_session_append(pp.as_ptr(), sid.as_ptr(), mj.as_ptr()));
1132 assert!(r.contains("\"success\":true"), "append without segments: {r}");
1133 let r = call_str(|| aacode_session_messages(pp.as_ptr(), sid.as_ptr(), 0, 50));
1134 assert!(r.contains("\"hi\""), "message without segments still read: {r}");
1135 assert!(r.contains("\"segments\":null"), "absent segments serialize as null: {r}");
1137 }
1138
1139 #[test]
1140 fn legacy_get_session_messages_carries_segments() {
1141 let proj = mkdirs(&tmp_root("sess_legacy_seg"));
1142 let pp = CString::new(proj.clone()).unwrap();
1143 let sid = CString::new("s1").unwrap();
1144 {
1145 let mut sm = SessionManager::new(std::path::Path::new(&proj));
1146 sm.ensure_session("s1", "").unwrap();
1147 sm.append_session_messages(
1148 "s1",
1149 vec![SessionMessage {
1150 role: "assistant".to_string(),
1151 content: "ok".to_string(),
1152 timestamp: "1700000001".to_string(),
1153 tokens: 1,
1154 tool_calls: None,
1155 tool_call_id: None,
1156 reasoning_content: None,
1157 segments: Some(vec![crate::session::MessageSegment {
1158 kind: "thought".into(),
1159 content: "ok".into(),
1160 name: None,
1161 created_at: Some("1700000001.001".into()),
1162 }]),
1163 }],
1164 )
1165 .unwrap();
1166 }
1167 let r = call_str(|| aacode_get_session_messages(pp.as_ptr(), sid.as_ptr()));
1168 assert!(r.contains("\"success\":true"), "legacy read: {r}");
1169 assert!(r.contains("\"type\":\"thought\""), "legacy carries segments: {r}");
1170 assert!(r.contains("\"created_at\":\"1700000001.001\""), "legacy segment created_at: {r}");
1171 }
1172
1173 #[test]
1174 fn session_list_empty_project() {
1175 let proj = mkdirs(&tmp_root("sess_empty"));
1176 let pp = CString::new(proj).unwrap();
1177 let r = call_str(|| aacode_session_list(pp.as_ptr()));
1178 assert!(r.contains("\"success\":true"));
1179 assert!(r.contains("\"sessions\":[]"), "empty project: {r}");
1180 }
1181}