1use crate::backend::{
83 AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
84};
85#[cfg(unix)]
86use crate::backend_claude::kill_group;
87#[cfg(windows)]
88use crate::backend_claude::win_job;
89use crate::cost;
90use crate::error::{EngineError, Result};
91use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
92use crate::types::TokenUsage;
93use serde_json::{json, Value};
94use std::collections::VecDeque;
95use std::path::{Path, PathBuf};
96use std::process::Stdio;
97use std::sync::{Arc, Mutex};
98use tokio::process::{Child, ChildStdout};
99use tokio::task::JoinHandle;
100
101const SUMMARY_MAX_CHARS: usize = 200;
103const STDERR_TAIL_CHARS: usize = 500;
105
106const CURSOR_AUTH_ENV: &str = "CURSOR_API_KEY";
110
111const CURSOR_SEED_ENTRIES: &[&str] = &["cli-config.json", "agent-cli-state.json"];
122
123const PRE_BILLING_FAILURE_PHRASES: &[&str] = &["cannot use this model", "authentication required"];
132
133#[cfg(target_os = "macos")]
168const SESSION_KEYCHAIN_LOCK_SECS: u32 = 8 * 60 * 60;
169
170#[cfg(target_os = "macos")]
171const SESSION_KEYCHAIN_DB: &str = "kranz-session.keychain-db";
172
173#[cfg(target_os = "macos")]
179static SESSION_KEYCHAIN_OPERATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
180
181#[cfg(target_os = "macos")]
184fn session_keychain_secret_path(home: &Path) -> PathBuf {
185 home.join("Library")
186 .join("Keychains")
187 .join(".login.keychain-passphrase")
188}
189
190#[cfg(target_os = "macos")]
201fn security_in_session_home(home: &Path, args: &[&std::ffi::OsStr]) -> std::io::Result<bool> {
202 let output = security_bounded(home, args, None)?;
203 Ok(output.status.success())
204}
205
206#[cfg(target_os = "macos")]
210const SECURITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
211
212#[cfg(target_os = "macos")]
219fn security_bounded(
220 home: &Path,
221 args: &[&std::ffi::OsStr],
222 stdin_script: Option<&str>,
223) -> std::io::Result<std::process::Output> {
224 security_bounded_with_timeout(
225 Path::new("security"),
226 home,
227 args,
228 stdin_script,
229 SECURITY_TIMEOUT,
230 )
231}
232
233#[cfg(target_os = "macos")]
239fn security_bounded_with_timeout(
240 binary: &Path,
241 home: &Path,
242 args: &[&std::ffi::OsStr],
243 stdin_script: Option<&str>,
244 timeout: std::time::Duration,
245) -> std::io::Result<std::process::Output> {
246 use std::io::Read as _;
247 use std::io::Write as _;
248 let mut cmd = std::process::Command::new(binary);
249 cmd.args(args)
250 .env_clear()
251 .env("HOME", home)
252 .env("PATH", "/usr/bin:/bin")
253 .stdout(std::process::Stdio::piped())
254 .stderr(std::process::Stdio::piped());
255 if stdin_script.is_some() {
256 cmd.stdin(std::process::Stdio::piped());
257 } else {
258 cmd.stdin(std::process::Stdio::null());
259 }
260 if let Ok(user) = std::env::var("USER") {
261 cmd.env("USER", user);
262 }
263 let mut child = cmd.spawn()?;
267 if let Some(script) = stdin_script {
268 if let Some(mut stdin) = child.stdin.take() {
269 let _ = stdin.write_all(script.as_bytes());
272 }
273 }
274 let deadline = std::time::Instant::now() + timeout;
275 let status = loop {
276 match child.try_wait() {
277 Ok(Some(status)) => break status,
278 Ok(None) if std::time::Instant::now() >= deadline => {
279 let _ = child.kill();
280 let _ = child.wait();
281 return Err(std::io::Error::new(
282 std::io::ErrorKind::TimedOut,
283 format!(
284 "security did not exit within {}s (killed; locked keychain?)",
285 timeout.as_secs()
286 ),
287 ));
288 }
289 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
290 Err(e) => {
291 let _ = child.kill();
292 let _ = child.wait();
293 return Err(e);
294 }
295 }
296 };
297 let mut stdout = Vec::new();
299 let mut stderr = Vec::new();
300 if let Some(mut out) = child.stdout.take() {
301 let _ = out.read_to_end(&mut stdout);
302 }
303 if let Some(mut err) = child.stderr.take() {
304 let _ = err.read_to_end(&mut stderr);
305 }
306 Ok(std::process::Output {
307 status,
308 stdout,
309 stderr,
310 })
311}
312
313#[cfg(target_os = "macos")]
320fn security_script_in_session_home(
321 home: &Path,
322 script: &str,
323) -> std::io::Result<std::process::Output> {
324 security_bounded(home, &[std::ffi::OsStr::new("-i")], Some(script))
325}
326
327#[cfg(target_os = "macos")]
331fn write_session_keychain_secret(path: &Path, secret: &str) -> std::io::Result<()> {
332 use std::io::Write as _;
333 use std::os::unix::fs::OpenOptionsExt as _;
334 use std::os::unix::fs::PermissionsExt as _;
335 let mut file = std::fs::OpenOptions::new()
336 .write(true)
337 .create(true)
338 .truncate(true)
339 .mode(0o600)
340 .custom_flags(libc::O_NOFOLLOW)
341 .open(path)?;
342 file.write_all(secret.as_bytes())?;
343 file.set_permissions(std::fs::Permissions::from_mode(0o600))
344}
345
346#[cfg(target_os = "macos")]
347fn read_session_keychain_secret(path: &Path) -> std::io::Result<Option<String>> {
348 use std::io::Read as _;
349 use std::os::unix::fs::OpenOptionsExt as _;
350 let file = match std::fs::OpenOptions::new()
351 .read(true)
352 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
353 .open(path)
354 {
355 Ok(file) => file,
356 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
357 Err(error) => return Err(error),
358 };
359 let mut contents = String::new();
360 if file.metadata()?.is_file() {
361 file.take(129).read_to_string(&mut contents)?;
362 }
363 if contents.len() != 32 || !contents.bytes().all(|byte| byte.is_ascii_hexdigit()) {
366 return Err(std::io::Error::new(
367 std::io::ErrorKind::InvalidData,
368 "invalid session keychain secret",
369 ));
370 }
371 Ok(Some(contents))
372}
373
374#[cfg(target_os = "macos")]
417fn ensure_session_login_keychain(home: &Path, session_id: &str) -> bool {
418 let _operation = SESSION_KEYCHAIN_OPERATION_LOCK
419 .lock()
420 .unwrap_or_else(|poison| poison.into_inner());
421 let keychains = home.join("Library").join("Keychains");
422 let normalized = crate::sandbox::absolutize(&keychains);
423 let Some(operator) = crate::agent_env::os_account_home() else {
424 tracing::warn!(
425 "cursor session keychain seed: cannot identify the operator's keychain directory"
426 );
427 return false;
428 };
429 if normalized != crate::sandbox::absolutize(home).join("Library/Keychains")
430 || normalized.starts_with(crate::sandbox::absolutize(
431 &operator.join("Library/Keychains"),
432 ))
433 {
434 tracing::warn!(
435 "cursor session keychain seed: refusing an operator or redirected keychain directory"
436 );
437 return false;
438 }
439 let db = keychains.join(SESSION_KEYCHAIN_DB);
440 let login = keychains.join("login.keychain-db");
441 let managed_exists = match std::fs::symlink_metadata(&db) {
444 Ok(metadata) if metadata.is_file() => true,
445 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
446 _ => return false,
447 };
448 let migrate_login = match std::fs::symlink_metadata(&login) {
449 Ok(metadata) if metadata.is_file() && !managed_exists => true,
450 Ok(metadata)
451 if metadata.file_type().is_symlink()
452 && std::fs::read_link(&login)
453 .is_ok_and(|target| target == Path::new(SESSION_KEYCHAIN_DB)) =>
454 {
455 false
456 }
457 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
458 _ => return false,
459 };
460 let db_exists = managed_exists || migrate_login;
461 if let Err(e) = std::fs::create_dir_all(&keychains) {
462 tracing::warn!(
463 error = %e,
464 "cursor session keychain seed: cannot create Library/Keychains; the CLI may \
465 fail startup with a security error under the relocated HOME"
466 );
467 return false;
468 }
469 let secret_path = session_keychain_secret_path(home);
470 let stored = match read_session_keychain_secret(&secret_path) {
471 Ok(stored) => stored,
472 Err(_) => {
473 tracing::warn!(
474 "cursor session keychain seed: refusing invalid or linked passphrase material"
475 );
476 return false;
477 }
478 };
479 let passphrase = match (stored, db_exists) {
480 (Some(secret), _) => secret,
483 (None, true)
487 if !session_id.is_empty()
488 && session_id
489 .bytes()
490 .all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte)) =>
491 {
492 format!("kranz-scratch-{session_id}")
493 }
494 (None, true) => return false,
495 (None, false) => {
496 let fresh = uuid::Uuid::new_v4().simple().to_string();
497 if let Err(e) = write_session_keychain_secret(&secret_path, &fresh) {
498 tracing::warn!(
499 error = %e,
500 "cursor session keychain seed: cannot persist the passphrase; the CLI may \
501 fail startup with a security error under the relocated HOME"
502 );
503 return false;
504 }
505 fresh
506 }
507 };
508 let Some(db_text) = db
509 .to_str()
510 .filter(|path| !path.chars().any(char::is_control))
511 else {
512 return false;
513 };
514 let db_text = db_text.replace('\\', "\\\\").replace('"', "\\\"");
515 if migrate_login && std::fs::rename(&login, &db).is_err() {
516 return false;
517 }
518 let restore_legacy = || {
519 if migrate_login && std::fs::symlink_metadata(&login).is_err() {
520 let _ = std::fs::rename(&db, &login);
521 }
522 };
523 let mut script = String::new();
528 if !db_exists {
529 script.push_str(&format!(
530 "create-keychain -p {passphrase} \"{}\"\n",
531 db_text
532 ));
533 }
534 script.push_str(&format!(
535 "unlock-keychain -p {passphrase} \"{}\"\n",
536 db_text
537 ));
538 match security_script_in_session_home(home, &script) {
539 Ok(output) if output.status.success() => {}
540 Ok(output) => {
541 let stderr = String::from_utf8_lossy(&output.stderr)
545 .replace(&passphrase, "<redacted>")
546 .trim()
547 .to_string();
548 tracing::warn!(
549 status = %output.status,
550 stderr = %stderr,
551 "cursor session keychain seed: unlock failed; the CLI may fail startup with \
552 a security error under the relocated HOME"
553 );
554 restore_legacy();
558 return false;
559 }
560 Err(e) => {
561 tracing::warn!(
562 error = %e,
563 "cursor session keychain seed: security failed to spawn; the CLI may fail \
564 startup with a security error under the relocated HOME"
565 );
566 restore_legacy();
567 return false;
568 }
569 }
570 if std::fs::symlink_metadata(&login).is_err()
571 && std::os::unix::fs::symlink(SESSION_KEYCHAIN_DB, &login).is_err()
572 {
573 restore_legacy();
574 return false;
575 }
576 let settings = format!(
579 "set-keychain-settings -lut {SESSION_KEYCHAIN_LOCK_SECS} \"{}\"\n",
580 db_text
581 );
582 match security_script_in_session_home(home, &settings) {
583 Ok(output) if output.status.success() => {}
584 Ok(output) => {
585 let stderr = String::from_utf8_lossy(&output.stderr)
586 .replace(&passphrase, "<redacted>")
587 .trim()
588 .to_string();
589 tracing::warn!(
590 status = %output.status,
591 stderr = %stderr,
592 "cursor session keychain seed: could not bound the auto-lock; the store keeps \
593 its current lock settings"
594 );
595 }
596 Err(e) => {
597 tracing::warn!(
598 error = %e,
599 "cursor session keychain seed: could not bound the auto-lock; the store keeps \
600 its current lock settings"
601 );
602 }
603 }
604 true
606}
607
608#[cfg(target_os = "macos")]
616fn lock_session_login_keychain(home: &Path) -> std::io::Result<bool> {
617 let _operation = SESSION_KEYCHAIN_OPERATION_LOCK
618 .lock()
619 .unwrap_or_else(std::sync::PoisonError::into_inner);
620 let db = home
621 .join("Library")
622 .join("Keychains")
623 .join(SESSION_KEYCHAIN_DB);
624 if !std::fs::symlink_metadata(&db).is_ok_and(|metadata| metadata.is_file()) {
625 return Ok(false);
626 }
627 security_in_session_home(
628 home,
629 &[std::ffi::OsStr::new("lock-keychain"), db.as_os_str()],
630 )
631}
632
633fn cursor_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
642 if spec.env.contains_key("HOME") {
643 #[cfg(target_os = "macos")]
646 if let Some(home) = spec.env.get("HOME") {
647 let _ = ensure_session_login_keychain(Path::new(home), &spec.session_id);
648 }
649 return crate::agent_env::agent_session_env(
650 &spec.env,
651 &spec.session_id,
652 Some(CURSOR_AUTH_ENV),
653 );
654 }
655 let real_home = std::env::var_os("HOME").map(PathBuf::from);
656 let scratch_root = crate::backend_claude::scratch_home_root(&spec.session_id);
657 match seed_cursor_scratch_home(&scratch_root, real_home.as_deref()) {
658 Ok(home) => {
659 #[cfg(target_os = "macos")]
660 let _ = ensure_session_login_keychain(&home, &spec.session_id);
661 tracing::info!(
662 session_id = %spec.session_id,
663 decision = "scratch-seeded",
664 "session spec carried no relocated HOME; spawning into a seeded scratch \
665 HOME (.cursor minimal account/config set)"
666 );
667 crate::agent_env::session_env_with_home(
668 &spec.env,
669 &spec.session_id,
670 Some(CURSOR_AUTH_ENV),
671 &home,
672 )
673 }
674 Err(e) => {
675 tracing::warn!(
676 session_id = %spec.session_id,
677 error = %e,
678 "cursor scratch HOME seeding failed; session spawns into an empty scratch \
679 HOME and will fail auth loudly if CURSOR_API_KEY is not injected"
680 );
681 crate::agent_env::agent_session_env(&spec.env, &spec.session_id, Some(CURSOR_AUTH_ENV))
682 }
683 }
684}
685
686fn seed_cursor_scratch_home(
692 scratch_root: &Path,
693 real_home: Option<&Path>,
694) -> std::io::Result<PathBuf> {
695 let home = scratch_root.join("home");
696 let cursor_dir = home.join(".cursor");
697 std::fs::create_dir_all(&cursor_dir)?;
698 if let Some(real_home) = real_home {
699 let source = real_home.join(".cursor");
700 for entry in CURSOR_SEED_ENTRIES {
701 let src = source.join(entry);
702 let dst = cursor_dir.join(entry);
703 if src.is_file() {
704 std::fs::copy(&src, &dst)?;
705 } else if src.is_dir() {
706 copy_dir_recursive(&src, &dst)?;
707 }
708 }
709 }
710 Ok(home)
711}
712
713fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
716 std::fs::create_dir_all(dst)?;
717 for entry in std::fs::read_dir(src)? {
718 let entry = entry?;
719 let file_type = entry.file_type()?;
720 let target = dst.join(entry.file_name());
721 if file_type.is_dir() {
722 copy_dir_recursive(&entry.path(), &target)?;
723 } else if file_type.is_file() {
724 std::fs::copy(entry.path(), &target)?;
725 }
726 }
727 Ok(())
728}
729
730fn cursor_session_home(spec: &SessionSpec) -> PathBuf {
740 if let Some(home) = spec.env.get("HOME") {
741 return PathBuf::from(home);
742 }
743 crate::backend_claude::scratch_home_root(&spec.session_id).join("home")
744}
745
746#[cfg(test)]
752static CURSOR_ENV_LOCK: Mutex<()> = Mutex::new(());
753
754pub fn discover_cursor_binary(configured: Option<&str>) -> Result<PathBuf> {
773 if let Some(env_bin) = std::env::var_os("KRANZ_CURSOR_BIN") {
774 if !env_bin.is_empty() {
775 let candidate = PathBuf::from(env_bin);
776 return match probe_version(&candidate) {
777 Ok(_version) => Ok(candidate),
778 Err(why) => Err(EngineError::Config(format!(
779 "KRANZ_CURSOR_BIN points at {} which did not work: {why}",
780 candidate.display()
781 ))),
782 };
783 }
784 }
785
786 let mut candidates: Vec<PathBuf> = Vec::new();
787 if let Some(configured) = configured {
788 candidates.push(PathBuf::from(configured));
789 }
790 candidates.push(PathBuf::from("agent"));
794 #[cfg(windows)]
795 {
796 candidates.push(PathBuf::from("agent.cmd"));
797 candidates.push(PathBuf::from("agent.exe"));
798 }
799 candidates.extend(fallback_candidates());
800
801 let mut deduped: Vec<PathBuf> = Vec::new();
803 for candidate in candidates {
804 if !deduped.contains(&candidate) {
805 deduped.push(candidate);
806 }
807 }
808
809 let mut attempts: Vec<String> = Vec::new();
810 for candidate in deduped {
811 match probe_version(&candidate) {
812 Ok(_version) => return Ok(candidate),
813 Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
814 }
815 }
816 Err(EngineError::Config(format!(
817 "no working cursor agent binary found; tried: {}. Install the Cursor \
818 CLI or point kranz at it via the KRANZ_CURSOR_BIN environment variable.",
819 attempts.join(", ")
820 )))
821}
822
823#[cfg(not(windows))]
826fn fallback_candidates() -> Vec<PathBuf> {
827 let home = std::env::var_os("HOME").map(PathBuf::from);
828 let mut out = Vec::new();
829 if let Some(home) = &home {
830 out.push(home.join(".npm-global").join("bin").join("agent"));
831 }
832 out.push(PathBuf::from("/opt/homebrew/bin/agent"));
833 out.push(PathBuf::from("/usr/local/bin/agent"));
834 if let Some(home) = &home {
835 out.push(home.join(".local").join("bin").join("agent"));
836 }
837 out
838}
839
840#[cfg(windows)]
842fn fallback_candidates() -> Vec<PathBuf> {
843 let mut out = Vec::new();
844 if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
845 for dir in [
846 profile.join("AppData").join("Roaming").join("npm"),
847 profile.join(".npm-global").join("bin"),
848 profile.join(".local").join("bin"),
849 ] {
850 for name in ["agent.cmd", "agent.exe", "agent"] {
851 out.push(dir.join(name));
852 }
853 }
854 }
855 out
856}
857
858const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
862
863fn probe_version(binary: &Path) -> std::result::Result<String, String> {
866 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
867}
868
869fn effective_prompt(spec: &SessionSpec) -> String {
878 let prompt_text = match &spec.prompt {
879 PromptMode::SingleShot(text) => text.as_str(),
880 PromptMode::Streaming(text) => text.as_str(),
881 };
882 match &spec.append_system_prompt {
883 Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
884 _ => prompt_text.to_string(),
885 }
886}
887
888pub fn build_args(spec: &SessionSpec) -> Vec<String> {
901 let mut args = vec![
902 "--print".into(),
903 "--output-format".into(),
904 "stream-json".into(),
905 "--trust".into(),
906 "--workspace".into(),
907 spec.cwd.display().to_string(),
908 "--model".into(),
909 spec.model.clone(),
910 ];
911 if spec.writable {
916 args.push("--force".into());
917 } else {
918 args.push("--mode".into());
919 args.push("ask".into());
920 }
921 args.push(effective_prompt(spec));
922 args
923}
924
925pub fn parse_cursor_line(line: &str, model: &str) -> Vec<AgentEvent> {
940 match serde_json::from_str::<Value>(line) {
941 Ok(value) => parse_cursor_value(value, model),
942 Err(_) => vec![AgentEvent::Other {
943 raw: json!({ "unparsed": line }),
944 }],
945 }
946}
947
948pub fn parse_cursor_value(value: Value, model: &str) -> Vec<AgentEvent> {
953 let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
954 match line_type {
955 "system" if str_field(&value, "subtype") == "init" => vec![AgentEvent::Init {
956 session_id: str_field(&value, "session_id"),
957 model: value
958 .get("model")
959 .and_then(Value::as_str)
960 .unwrap_or(model)
961 .to_string(),
962 raw: value,
963 }],
964 "user" | "system" => vec![AgentEvent::Other { raw: value }],
967 "assistant" => {
968 let text = assistant_text(&value);
969 if text.is_empty() {
970 vec![AgentEvent::Other { raw: value }]
971 } else {
972 vec![AgentEvent::Text { text, raw: value }]
973 }
974 }
975 "tool_call" => match str_field(&value, "subtype").as_str() {
976 "started" => vec![parse_tool_use(value)],
977 "completed" => parse_tool_result(value),
978 _ => vec![AgentEvent::Other { raw: value }],
979 },
980 "result" => vec![parse_terminal(value, model)],
981 _ => vec![AgentEvent::Other { raw: value }],
982 }
983}
984
985fn assistant_text(value: &Value) -> String {
989 let mut out = String::new();
990 if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
991 for block in blocks {
992 if block.get("type").and_then(Value::as_str) == Some("text") {
993 if let Some(text) = block.get("text").and_then(Value::as_str) {
994 out.push_str(text);
995 }
996 }
997 }
998 }
999 out
1000}
1001
1002fn tool_kind(value: &Value) -> String {
1008 value
1009 .get("tool_call")
1010 .and_then(Value::as_object)
1011 .and_then(|obj| obj.keys().find(|k| k.ends_with("ToolCall")).cloned())
1012 .unwrap_or_else(|| "tool".to_string())
1013}
1014
1015fn parse_tool_use(value: Value) -> AgentEvent {
1019 let kind = tool_kind(&value);
1020 let args = value.pointer(&format!("/tool_call/{kind}/args"));
1021 let summary = args
1022 .and_then(|args| {
1023 args.get("command")
1024 .or_else(|| args.get("path"))
1025 .and_then(Value::as_str)
1026 })
1027 .or_else(|| {
1028 value
1029 .pointer(&format!("/tool_call/{kind}/description"))
1030 .and_then(Value::as_str)
1031 })
1032 .unwrap_or("");
1033 AgentEvent::ToolUse {
1034 tool: kind,
1035 summary: truncate_chars(summary, SUMMARY_MAX_CHARS),
1036 raw: value,
1037 }
1038}
1039
1040fn parse_tool_result(value: Value) -> Vec<AgentEvent> {
1049 let kind = tool_kind(&value);
1050 let result = value.pointer(&format!("/tool_call/{kind}/result"));
1051 let Some(result) = result else {
1052 return vec![AgentEvent::Other { raw: value }];
1053 };
1054 let summary = if let Some(success) = result.get("success") {
1055 success
1056 .get("stdout")
1057 .or_else(|| success.get("message"))
1058 .or_else(|| success.get("content"))
1059 .or_else(|| success.get("diffString"))
1060 .and_then(Value::as_str)
1061 .map(str::to_string)
1062 .unwrap_or_else(|| success.to_string())
1063 } else if let Some(failure) = result.get("failure") {
1064 failure
1065 .get("stderr")
1066 .or_else(|| failure.get("stdout"))
1067 .and_then(Value::as_str)
1068 .filter(|s| !s.is_empty())
1069 .map(str::to_string)
1070 .or_else(|| {
1071 failure
1072 .get("exitCode")
1073 .and_then(Value::as_i64)
1074 .map(|code| format!("exit code {code}"))
1075 })
1076 .unwrap_or_else(|| failure.to_string())
1077 } else {
1078 return vec![AgentEvent::Other { raw: value }];
1079 };
1080 vec![AgentEvent::ToolResult {
1081 tool: Some(kind),
1082 denied: false,
1083 summary: truncate_chars(&summary, SUMMARY_MAX_CHARS),
1084 raw: value,
1085 }]
1086}
1087
1088fn parse_terminal(value: Value, model: &str) -> AgentEvent {
1098 let usage_present = value.get("usage").is_some();
1099 let usage_field = |key: &str| {
1100 value
1101 .pointer(&format!("/usage/{key}"))
1102 .and_then(Value::as_u64)
1103 .unwrap_or(0)
1104 };
1105 let usage = TokenUsage {
1106 input: usage_field("inputTokens"),
1107 output: usage_field("outputTokens"),
1108 cache_read: usage_field("cacheReadTokens"),
1109 cache_write: usage_field("cacheWriteTokens"),
1110 };
1111 let cost_usd = usage_present.then(|| cost::usage_cost_usd(&usage, model));
1112 let is_error = value
1113 .get("is_error")
1114 .and_then(Value::as_bool)
1115 .unwrap_or(false)
1116 || str_field(&value, "subtype") == "error";
1117 AgentEvent::Result {
1118 text: str_field(&value, "result"),
1119 is_error,
1120 usage,
1121 cost_usd,
1122 num_turns: Some(1),
1123 raw: value,
1124 }
1125}
1126
1127fn names_pre_billing_failure(line: &str) -> bool {
1142 let lower = line.trim_start().to_ascii_lowercase();
1143 PRE_BILLING_FAILURE_PHRASES
1144 .iter()
1145 .any(|phrase| lower.starts_with(phrase))
1146}
1147
1148fn str_field(value: &Value, key: &str) -> String {
1149 value
1150 .get(key)
1151 .and_then(Value::as_str)
1152 .unwrap_or_default()
1153 .to_string()
1154}
1155
1156fn truncate_chars(text: &str, max: usize) -> String {
1158 if text.chars().count() <= max {
1159 text.to_string()
1160 } else {
1161 text.chars().take(max).collect()
1162 }
1163}
1164
1165fn last_chars(text: &str, max: usize) -> String {
1167 let chars: Vec<char> = text.chars().collect();
1168 let start = chars.len().saturating_sub(max);
1169 chars[start..].iter().collect()
1170}
1171
1172#[derive(Debug, Clone)]
1180pub struct CursorBackend {
1181 binary: PathBuf,
1182}
1183
1184impl CursorBackend {
1185 pub fn new(binary: impl Into<PathBuf>) -> Self {
1187 CursorBackend {
1188 binary: binary.into(),
1189 }
1190 }
1191
1192 pub fn discover(configured: Option<&str>) -> Result<Self> {
1194 Ok(CursorBackend {
1195 binary: discover_cursor_binary(configured)?,
1196 })
1197 }
1198
1199 pub fn binary(&self) -> &Path {
1201 &self.binary
1202 }
1203}
1204
1205#[async_trait::async_trait]
1206impl AgentBackend for CursorBackend {
1207 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
1208 if spec.resume.is_some() {
1209 return Err(EngineError::Backend(
1210 "cursor backend is single-shot only; resume is unsupported".to_string(),
1211 ));
1212 }
1213 let model = spec.model.clone();
1214 let args = build_args(&spec);
1215
1216 let child_env = cursor_child_env(&spec);
1222
1223 if let Some(seed) = &spec.hook_status {
1229 if let Err(e) = crate::hook_status::install_cursor_hook_status(
1230 &cursor_session_home(&spec),
1231 seed,
1232 &spec.session_id,
1233 ) {
1234 tracing::warn!(
1235 session_id = %spec.session_id,
1236 error = %e,
1237 "hook-status install failed; the session spawns without the lane \
1238 (mission state is unaffected — the lane is observational)"
1239 );
1240 }
1241 }
1242
1243 let mut command = tokio::process::Command::new(&self.binary);
1244 command
1245 .args(&args)
1246 .current_dir(&spec.cwd)
1247 .env_clear()
1248 .envs(child_env)
1249 .stdin(Stdio::null())
1250 .stdout(Stdio::piped())
1251 .stderr(Stdio::piped())
1252 .kill_on_drop(true);
1253 #[cfg(unix)]
1256 command.process_group(0);
1257
1258 let mut child = command.spawn().map_err(|e| {
1259 EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
1260 })?;
1261
1262 #[cfg(windows)]
1264 let job = match child.raw_handle() {
1265 Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
1266 Ok(job) => Some(job),
1267 Err(e) => {
1268 tracing::warn!(error = %e, "failed to create Job Object for cursor child; \
1269 tree-kill on abort will be unavailable");
1270 None
1271 }
1272 },
1273 None => None,
1274 };
1275
1276 let stdout = child
1277 .stdout
1278 .take()
1279 .ok_or_else(|| EngineError::Backend("cursor child has no stdout pipe".to_string()))?;
1280 let stderr = child
1281 .stderr
1282 .take()
1283 .ok_or_else(|| EngineError::Backend("cursor child has no stderr pipe".to_string()))?;
1284
1285 let stderr_buf = Arc::new(Mutex::new(String::new()));
1290 let stderr_task = {
1291 let buf = Arc::clone(&stderr_buf);
1292 tokio::spawn(async move {
1293 let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
1294 *buf.lock().expect("stderr buffer lock") = tail;
1295 })
1296 };
1297
1298 Ok(Box::new(CursorSession {
1299 session_id: spec.session_id.clone(),
1300 model,
1301 #[cfg(target_os = "macos")]
1302 session_home: cursor_session_home(&spec),
1303 child,
1304 #[cfg(windows)]
1305 job,
1306 lines: BoundedLines::new(stdout),
1307 stderr_buf,
1308 stderr_task: Some(stderr_task),
1309 queue: VecDeque::new(),
1310 saw_result: false,
1311 saw_success_result: false,
1312 pre_billing_failure: None,
1313 exit: None,
1314 }))
1315 }
1316}
1317
1318pub struct CursorSession {
1328 session_id: String,
1329 model: String,
1330 #[cfg(target_os = "macos")]
1334 session_home: PathBuf,
1335 child: Child,
1336 #[cfg(windows)]
1337 job: Option<win_job::JobHandle>,
1338 lines: BoundedLines<ChildStdout>,
1339 stderr_buf: Arc<Mutex<String>>,
1340 stderr_task: Option<JoinHandle<()>>,
1341 queue: VecDeque<AgentEvent>,
1343 saw_result: bool,
1344 saw_success_result: bool,
1345 pre_billing_failure: Option<String>,
1350 exit: Option<SessionExit>,
1351}
1352
1353#[cfg(unix)]
1354impl Drop for CursorSession {
1355 fn drop(&mut self) {
1356 crate::backend_claude::kill_unreaped_group(&self.child);
1357 }
1358}
1359
1360impl CursorSession {
1361 fn observe(&mut self, event: &AgentEvent) {
1362 match event {
1363 AgentEvent::Init { session_id, .. } => {
1364 self.session_id = session_id.clone();
1365 }
1366 AgentEvent::Result { is_error, .. } => {
1367 self.saw_result = true;
1368 if !is_error {
1369 self.saw_success_result = true;
1370 }
1371 }
1372 AgentEvent::Other { raw } if self.pre_billing_failure.is_none() => {
1373 if let Some(line) = raw.get("unparsed").and_then(Value::as_str) {
1374 if names_pre_billing_failure(line) {
1375 self.pre_billing_failure = Some(truncate_chars(line, STDERR_TAIL_CHARS));
1376 }
1377 }
1378 }
1379 _ => {}
1380 }
1381 }
1382
1383 async fn kill_child(&mut self) {
1388 #[cfg(unix)]
1389 {
1390 let pgid = self
1391 .child
1392 .id()
1393 .and_then(|pid| i32::try_from(pid).ok())
1394 .filter(|pid| *pid > 0);
1395 let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
1396 if !group_killed {
1397 let _ = self.child.start_kill();
1398 }
1399 let _ = self.child.wait().await;
1400 if group_killed {
1401 if let Some(pgid) = pgid {
1402 let _ = kill_group(pgid);
1403 }
1404 }
1405 }
1406 #[cfg(windows)]
1407 {
1408 match &self.job {
1409 Some(job) => job.kill(),
1410 None => {
1411 let _ = self.child.start_kill();
1412 }
1413 }
1414 let _ = self.child.wait().await;
1415 }
1416 #[cfg(all(not(unix), not(windows)))]
1417 {
1418 let _ = self.child.start_kill();
1419 let _ = self.child.wait().await;
1420 }
1421 if let Some(task) = self.stderr_task.take() {
1422 let _ = task.await;
1423 }
1424 #[cfg(target_os = "macos")]
1428 let _ = lock_session_login_keychain(&self.session_home);
1429 }
1430
1431 async fn finish_at_eof(&mut self) {
1432 let status = self.child.wait().await;
1433 if let Some(task) = self.stderr_task.take() {
1434 let _ = task.await;
1435 }
1436 #[cfg(target_os = "macos")]
1438 let _ = lock_session_login_keychain(&self.session_home);
1439 let completed = matches!(status, Ok(ref s) if s.success()) && self.saw_result;
1449 let pre_billing = if completed {
1450 None
1451 } else {
1452 self.pre_billing_failure.clone().or_else(|| {
1453 let tail = self.stderr_tail();
1454 tail.lines().any(names_pre_billing_failure).then_some(tail)
1455 })
1456 };
1457 let exit = match (status, pre_billing) {
1458 (Ok(status), Some(detail)) => SessionExit::Failed(format!(
1459 "cursor rejected the session before any billed turn (exit {status}): {detail} — \
1460 fix the configured model id or authenticate the cursor CLI; this is not a \
1461 retryable failure"
1462 )),
1463 (Ok(status), None) if status.success() && self.saw_result => SessionExit::Completed,
1464 (Ok(status), None) => SessionExit::Failed(format!(
1465 "cursor exited with {status}{}; stderr tail: {}",
1466 if self.saw_result {
1467 ""
1468 } else {
1469 " without emitting a terminal event"
1470 },
1471 self.stderr_tail(),
1472 )),
1473 (Err(e), _) => SessionExit::Failed(format!(
1474 "failed to reap cursor process: {e}; stderr tail: {}",
1475 self.stderr_tail(),
1476 )),
1477 };
1478 self.exit = Some(exit);
1479 }
1480
1481 fn stderr_tail(&self) -> String {
1482 let captured = self
1483 .stderr_buf
1484 .lock()
1485 .map(|guard| guard.clone())
1486 .unwrap_or_default();
1487 last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
1488 }
1489}
1490
1491#[async_trait::async_trait]
1492impl AgentSession for CursorSession {
1493 fn session_id(&self) -> String {
1494 self.session_id.clone()
1495 }
1496
1497 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
1498 loop {
1499 if let Some(event) = self.queue.pop_front() {
1500 return Ok(Some(event));
1501 }
1502 if self.exit.is_some() {
1503 return Ok(None);
1504 }
1505 let line = match self.lines.next_line().await {
1506 Ok(Some(line)) => line,
1507 Ok(None) => {
1508 self.finish_at_eof().await;
1509 return Ok(None);
1510 }
1511 Err(e) => {
1512 self.kill_child().await;
1513 self.exit = Some(SessionExit::Failed(format!(
1514 "error reading cursor stdout: {e}; stderr tail: {}",
1515 self.stderr_tail(),
1516 )));
1517 return Ok(None);
1518 }
1519 };
1520 if line.trim().is_empty() {
1521 continue;
1522 }
1523 let events = parse_cursor_line(&line, &self.model);
1524 for event in &events {
1525 self.observe(event);
1526 }
1527 self.queue.extend(events);
1528 }
1529 }
1530
1531 async fn send_user_message(&mut self, _text: &str) -> Result<()> {
1532 Err(EngineError::Backend(
1533 "cursor backend is single-shot only; send_user_message is unsupported".to_string(),
1534 ))
1535 }
1536
1537 async fn abort(&mut self) -> Result<()> {
1538 let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
1539 self.kill_child().await;
1540 if self.saw_success_result && already_exited {
1541 self.exit = Some(SessionExit::Completed);
1542 } else {
1543 self.exit = Some(SessionExit::Aborted);
1544 }
1545 Ok(())
1546 }
1547
1548 fn exit_status(&self) -> Option<SessionExit> {
1549 self.exit.clone()
1550 }
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556
1557 const TEST_MODEL: &str = "gpt-5";
1558
1559 fn fixture_lines() -> Vec<String> {
1560 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1561 .join("..")
1562 .join("..")
1563 .join("docs")
1564 .join("scoping")
1565 .join("cursor-probe-evidence")
1566 .join("fixture-stream-json.jsonl");
1567 std::fs::read_to_string(path)
1568 .expect("read fixture")
1569 .lines()
1570 .filter(|line| !line.trim().is_empty())
1571 .map(|line| line.to_string())
1572 .collect()
1573 }
1574
1575 fn spec(cwd: &Path, writable: bool) -> SessionSpec {
1576 SessionSpec {
1577 cwd: cwd.to_path_buf(),
1578 prompt: PromptMode::SingleShot("do the thing".to_string()),
1579 append_system_prompt: None,
1580 model: TEST_MODEL.to_string(),
1581 effort: "high".to_string(),
1582 session_id: "sess-1".to_string(),
1583 resume: None,
1584 permission_mode: None,
1585 allowed_tools: vec![],
1586 disallowed_tools: vec![],
1587 tools: vec![],
1588 writable,
1589 settings_json: None,
1590 json_schema: None,
1591 max_budget_usd: None,
1592 max_turns: None,
1593 env: Default::default(),
1594 sandbox: None,
1595 hook_status: None,
1596 }
1597 }
1598
1599 #[test]
1603 fn seed_cursor_scratch_home_copies_the_minimal_state_set() {
1604 let real_home = tempfile::tempdir().unwrap();
1605 let cursor = real_home.path().join(".cursor");
1606 std::fs::create_dir_all(cursor.join("chats")).unwrap();
1607 std::fs::write(cursor.join("cli-config.json"), "{}").unwrap();
1608 std::fs::write(cursor.join("agent-cli-state.json"), "{}").unwrap();
1609 std::fs::write(cursor.join("chats").join("big.jsonl"), "transcript").unwrap();
1610 std::fs::write(cursor.join("prompt_history.json"), "[]").unwrap();
1611
1612 let scratch = tempfile::tempdir().unwrap();
1613 let home = seed_cursor_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1614
1615 let seeded = home.join(".cursor");
1616 assert!(seeded.join("cli-config.json").is_file());
1617 assert!(seeded.join("agent-cli-state.json").is_file());
1618 assert!(
1619 !seeded.join("chats").exists(),
1620 "per-session transcripts are never seeded"
1621 );
1622 assert!(
1623 !seeded.join("prompt_history.json").exists(),
1624 "unbounded history is never seeded"
1625 );
1626 }
1627
1628 #[test]
1631 fn seed_cursor_scratch_home_without_a_source_yields_an_empty_seed() {
1632 let real_home = tempfile::tempdir().unwrap();
1633 let scratch = tempfile::tempdir().unwrap();
1634
1635 let home = seed_cursor_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1636
1637 let seeded = home.join(".cursor");
1638 assert!(seeded.is_dir());
1639 assert_eq!(std::fs::read_dir(&seeded).unwrap().count(), 0);
1640 }
1641
1642 #[test]
1646 fn cursor_child_env_without_relocated_home_seeds_cursor_config() {
1647 let real_home = tempfile::tempdir().unwrap();
1648 let cursor = real_home.path().join(".cursor");
1649 std::fs::create_dir_all(&cursor).unwrap();
1650 std::fs::write(cursor.join("cli-config.json"), "{}").unwrap();
1651 std::fs::write(cursor.join("agent-cli-state.json"), "{}").unwrap();
1652
1653 let _home_guard =
1654 crate::agent_env::EnvTestGuard::engage(&[("HOME", real_home.path().to_str().unwrap())]);
1655 let session_spec = spec(Path::new("."), false);
1656
1657 let env = cursor_child_env(&session_spec);
1658
1659 let home = env.get("HOME").expect("child env carries HOME");
1660 let seeded = Path::new(home).join(".cursor");
1661 assert!(
1662 seeded.join("cli-config.json").is_file(),
1663 "validator-path HOME must carry the seeded cli-config.json"
1664 );
1665 assert!(
1666 seeded.join("agent-cli-state.json").is_file(),
1667 "validator-path HOME must carry the seeded agent-cli-state.json"
1668 );
1669 }
1670
1671 #[cfg(target_os = "macos")]
1677 #[test]
1678 fn security_bounded_kills_a_locked_keychain_hang_at_the_deadline() {
1679 use std::os::unix::fs::PermissionsExt as _;
1680 let dir = tempfile::tempdir().unwrap();
1681 let stub = dir.path().join("hung-security");
1682 std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
1683 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
1684 let home = tempfile::tempdir().unwrap();
1685
1686 let start = std::time::Instant::now();
1687 let result = security_bounded_with_timeout(
1688 &stub,
1689 home.path(),
1690 &[std::ffi::OsStr::new("find-generic-password")],
1691 None,
1692 std::time::Duration::from_secs(1),
1693 );
1694
1695 let error = result.expect_err("a hung security must be reported as timed out");
1696 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut, "{error}");
1697 assert!(
1698 error.to_string().contains("did not exit within 1s"),
1699 "the error names the bound: {error}"
1700 );
1701 assert!(
1702 start.elapsed() < std::time::Duration::from_secs(10),
1703 "killed at the deadline, not after the stub's 30s sleep"
1704 );
1705 }
1706
1707 #[cfg(target_os = "macos")]
1724 fn keychain_can_be_created() -> bool {
1725 let home = tempfile::tempdir().unwrap();
1726 if ensure_session_login_keychain(home.path(), "capability-probe") {
1727 return true;
1728 }
1729 crate::test_capability::skip(
1730 crate::test_capability::capability::KEYCHAIN,
1731 "security cannot create and unlock a login keychain under a relocated HOME",
1732 );
1733 false
1734 }
1735
1736 #[cfg(target_os = "macos")]
1737 fn security_output(home: &Path, args: &[&str]) -> std::process::Output {
1738 std::process::Command::new("security")
1739 .args(args)
1740 .env_clear()
1741 .env("HOME", home)
1742 .env("PATH", "/usr/bin:/bin")
1743 .output()
1744 .unwrap()
1745 }
1746
1747 #[cfg(target_os = "macos")]
1754 #[test]
1755 fn cursor_keychain_seeded_empty_when_absent() {
1756 if !keychain_can_be_created() {
1757 return;
1758 }
1759 let home = tempfile::tempdir().unwrap();
1760
1761 assert!(ensure_session_login_keychain(home.path(), "test-session"));
1762
1763 let db = home
1764 .path()
1765 .join("Library")
1766 .join("Keychains")
1767 .join("login.keychain-db");
1768 assert_eq!(
1769 std::fs::read_link(&db).unwrap(),
1770 Path::new(SESSION_KEYCHAIN_DB)
1771 );
1772 let backing = db.parent().unwrap().join(SESSION_KEYCHAIN_DB);
1773 let meta = std::fs::symlink_metadata(&backing).unwrap();
1774 assert!(
1775 meta.is_file(),
1776 "the backing store is a private regular file"
1777 );
1778 assert!(meta.len() > 0, "security create-keychain writes a real db");
1779 assert!(keychain_is_unlocked(&db));
1780 let unlock_material =
1784 std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap();
1785 assert!(!unlock_material.is_empty());
1786 }
1787
1788 #[cfg(target_os = "macos")]
1791 #[test]
1792 fn cursor_keychain_never_replaces_an_existing_db() {
1793 if !keychain_can_be_created() {
1794 return;
1795 }
1796 let home = tempfile::tempdir().unwrap();
1797 let keychains = home.path().join("Library").join("Keychains");
1798 std::fs::create_dir_all(&keychains).unwrap();
1799 let db = keychains.join("login.keychain-db");
1800 std::fs::write(&db, b"sentinel").unwrap();
1801
1802 let _ = ensure_session_login_keychain(home.path(), "test-session");
1803
1804 assert_eq!(std::fs::read(&db).unwrap(), b"sentinel");
1805 }
1806
1807 #[cfg(target_os = "macos")]
1813 #[test]
1814 fn cursor_keychain_hardened_secret_is_random_per_session_and_stored_0600() {
1815 if !keychain_can_be_created() {
1816 return;
1817 }
1818 use std::os::unix::fs::PermissionsExt as _;
1819 let home_a = tempfile::tempdir().unwrap();
1820 let home_b = tempfile::tempdir().unwrap();
1821
1822 let start = std::sync::Barrier::new(3);
1827 let (seeded_a, seeded_b) = std::thread::scope(|scope| {
1828 let a = scope.spawn(|| {
1829 start.wait();
1830 ensure_session_login_keychain(home_a.path(), "test-session")
1831 });
1832 let b = scope.spawn(|| {
1833 start.wait();
1834 ensure_session_login_keychain(home_b.path(), "test-session")
1835 });
1836 start.wait();
1837 (a.join().unwrap(), b.join().unwrap())
1838 });
1839 assert!(seeded_a);
1840 assert!(seeded_b);
1841
1842 let path_a = session_keychain_secret_path(home_a.path());
1843 let secret_a = std::fs::read_to_string(&path_a).unwrap();
1844 let secret_b =
1845 std::fs::read_to_string(session_keychain_secret_path(home_b.path())).unwrap();
1846 assert_ne!(
1847 secret_a, secret_b,
1848 "each session gets its own random secret"
1849 );
1850 assert!(!secret_a.contains("test-session"));
1853 assert_eq!(secret_a.len(), 32, "a uuid v4 simple secret is 128 bits");
1854 assert!(secret_a.chars().all(|c| c.is_ascii_hexdigit()));
1855 let mode = std::fs::metadata(&path_a).unwrap().permissions().mode() & 0o777;
1856 assert_eq!(
1857 mode, 0o600,
1858 "the secret file must be owner-only, got {mode:o}"
1859 );
1860
1861 assert!(ensure_session_login_keychain(home_a.path(), "test-session"));
1862 assert_eq!(
1863 std::fs::read_to_string(&path_a).unwrap(),
1864 secret_a,
1865 "a respawn into the same HOME reuses the stored secret"
1866 );
1867 }
1868
1869 #[cfg(target_os = "macos")]
1872 #[test]
1873 fn cursor_keychain_hardened_lock_timeout_is_bounded_and_unlocked() {
1874 if !keychain_can_be_created() {
1875 return;
1876 }
1877 let home = tempfile::tempdir().unwrap();
1878
1879 assert!(
1880 ensure_session_login_keychain(home.path(), "test-session"),
1881 "the seed's own unlock witness: batch A exited 0, so the store \
1882 is known-unlocked and show-keychain-info below cannot prompt"
1883 );
1884
1885 let db = home
1886 .path()
1887 .join("Library")
1888 .join("Keychains")
1889 .join("login.keychain-db");
1890 let info = security_output(home.path(), &["show-keychain-info", db.to_str().unwrap()]);
1894 assert!(
1895 info.status.success(),
1896 "show-keychain-info on the known-unlocked db: {}",
1897 String::from_utf8_lossy(&info.stderr)
1898 );
1899 let info_text = format!(
1901 "{}{}",
1902 String::from_utf8_lossy(&info.stdout),
1903 String::from_utf8_lossy(&info.stderr)
1904 );
1905 assert!(
1906 info_text.contains(&format!("timeout={SESSION_KEYCHAIN_LOCK_SECS}s")),
1907 "the auto-lock is bounded, never no-timeout: {info_text}"
1908 );
1909 }
1910
1911 #[cfg(target_os = "macos")]
1921 #[test]
1922 fn cursor_keychain_hardened_teardown_relocks_the_store() {
1923 if !keychain_can_be_created() {
1924 return;
1925 }
1926 let home = tempfile::tempdir().unwrap();
1927 assert!(ensure_session_login_keychain(home.path(), "test-session"));
1928 let backing = home
1929 .path()
1930 .join("Library/Keychains")
1931 .join(SESSION_KEYCHAIN_DB);
1932 assert!(keychain_is_unlocked(&backing));
1933
1934 let ran = lock_session_login_keychain(home.path()).unwrap();
1935 assert!(ran, "the teardown hook ran lock-keychain on the session db");
1936 assert!(
1937 !keychain_is_unlocked(&backing),
1938 "teardown left the store unlocked"
1939 );
1940
1941 let again = lock_session_login_keychain(home.path()).unwrap();
1942 assert!(
1943 again,
1944 "relocking an already-locked db neither prompts nor errors"
1945 );
1946
1947 let db = home
1951 .path()
1952 .join("Library")
1953 .join("Keychains")
1954 .join("login.keychain-db");
1955 let unlock_material =
1956 std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap();
1957 assert!(
1958 security_output(
1959 home.path(),
1960 &[
1961 "unlock-keychain",
1962 "-p",
1963 &unlock_material,
1964 db.to_str().unwrap(),
1965 ]
1966 )
1967 .status
1968 .success(),
1969 "the stored secret re-unlocks after teardown"
1970 );
1971 assert!(keychain_is_unlocked(&backing));
1972 }
1973
1974 #[cfg(target_os = "macos")]
1975 fn keychain_is_unlocked(path: &Path) -> bool {
1976 use std::ffi::{c_char, c_void, CString};
1977 #[link(name = "Security", kind = "framework")]
1978 unsafe extern "C" {
1979 fn SecKeychainOpen(path: *const c_char, keychain: *mut *mut c_void) -> i32;
1980 fn SecKeychainGetStatus(keychain: *mut c_void, status: *mut u32) -> i32;
1981 }
1982 #[link(name = "CoreFoundation", kind = "framework")]
1983 unsafe extern "C" {
1984 fn CFRelease(value: *const c_void);
1985 }
1986 let path = CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
1987 let mut keychain = std::ptr::null_mut();
1988 let mut status = 0;
1989 unsafe {
1990 assert_eq!(SecKeychainOpen(path.as_ptr(), &mut keychain), 0);
1991 let result = SecKeychainGetStatus(keychain, &mut status);
1992 CFRelease(keychain);
1993 assert_eq!(result, 0);
1994 }
1995 status & 1 != 0 }
1997
1998 #[cfg(target_os = "macos")]
1999 #[test]
2000 fn cursor_keychain_refuses_operator_paths_and_injected_secret_scripts() {
2001 if let Some(operator) = crate::agent_env::os_account_home() {
2002 assert!(!ensure_session_login_keychain(&operator, "test-session"));
2003 }
2004 let home = tempfile::tempdir().unwrap();
2005 let keychains = home.path().join("Library/Keychains");
2006 std::fs::create_dir_all(&keychains).unwrap();
2007 let injected = "bad\nlock-keychain\n";
2008 std::fs::write(session_keychain_secret_path(home.path()), injected).unwrap();
2009 assert!(!ensure_session_login_keychain(home.path(), "test-session"));
2010 assert!(!keychains.join(SESSION_KEYCHAIN_DB).exists());
2011 assert_eq!(
2012 std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap(),
2013 injected
2014 );
2015 }
2016
2017 #[cfg(target_os = "macos")]
2025 #[test]
2026 fn cursor_keychain_hardened_legacy_seed_still_unlocks() {
2027 if !keychain_can_be_created() {
2028 return;
2029 }
2030 let home = tempfile::tempdir().unwrap();
2031 let keychains = home.path().join("Library").join("Keychains");
2032 std::fs::create_dir_all(&keychains).unwrap();
2033 let db = keychains.join("login.keychain-db");
2034 let created = security_output(
2036 home.path(),
2037 &[
2038 "create-keychain",
2039 "-p",
2040 "kranz-scratch-test-session",
2041 db.to_str().unwrap(),
2042 ],
2043 );
2044 assert!(created.status.success());
2045 let locked = security_output(home.path(), &["lock-keychain", db.to_str().unwrap()]);
2046 assert!(locked.status.success());
2047
2048 assert!(
2049 ensure_session_login_keychain(home.path(), "test-session"),
2050 "the legacy derived passphrase still unlocks the pre-hardening db"
2051 );
2052 }
2053
2054 #[test]
2056 fn cursor_child_env_injects_the_sanctioned_api_key_and_never_ambient_secrets() {
2057 let _poison = crate::agent_env::EnvTestGuard::engage(&[
2058 ("CURSOR_API_KEY", "hunter2"),
2059 ("GH_TOKEN", "ghp-poison"),
2060 ("SLACK_BOT_TOKEN", "xoxb-poison"),
2061 ]);
2062 let session_spec = spec(Path::new("."), false);
2063
2064 let env = cursor_child_env(&session_spec);
2065
2066 assert_eq!(
2067 env.get("CURSOR_API_KEY").map(String::as_str),
2068 Some("hunter2"),
2069 "the sanctioned auth var must be injected explicitly"
2070 );
2071 for secret in ["GH_TOKEN", "SLACK_BOT_TOKEN", "ANTHROPIC_API_KEY"] {
2072 assert!(!env.contains_key(secret), "child env leaked {secret}");
2073 }
2074 }
2075
2076 #[test]
2077 #[cfg(unix)]
2078 fn cursor_probe_version_kills_a_hung_binary_within_the_deadline() {
2079 use std::os::unix::fs::PermissionsExt;
2080 let dir = tempfile::tempdir().unwrap();
2081 let stub = dir.path().join("hung-agent");
2082 std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
2083 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
2084
2085 let start = std::time::Instant::now();
2086 let result = probe_version(&stub);
2087
2088 let error = result.expect_err("a hung probe must be reported as broken");
2089 assert!(error.contains("did not exit"), "{error}");
2090 assert!(
2091 start.elapsed() < std::time::Duration::from_secs(10),
2092 "probe returned within the deadline, not after the stub's sleep"
2093 );
2094 }
2095
2096 #[test]
2097 fn cursor_discovery_honors_env_override_exclusively() {
2098 let _env_lock = crate::agent_env::ENV_TEST_LOCK
2102 .lock()
2103 .unwrap_or_else(|e| e.into_inner());
2104 let _guard = CURSOR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2105 let dir = tempfile::tempdir().unwrap();
2106 let working = dir.path().join("working-agent");
2107 #[cfg(unix)]
2108 {
2109 use std::os::unix::fs::PermissionsExt;
2110 std::fs::write(&working, "#!/bin/sh\necho 2026.07.08-test\n").unwrap();
2111 std::fs::set_permissions(&working, std::fs::Permissions::from_mode(0o755)).unwrap();
2112 }
2113 let bogus = dir.path().join("does-not-exist-agent");
2114
2115 std::env::set_var("KRANZ_CURSOR_BIN", &bogus);
2116 let result = discover_cursor_binary(Some(working.to_str().unwrap()));
2117 std::env::remove_var("KRANZ_CURSOR_BIN");
2118
2119 let error = result.expect_err("a broken KRANZ_CURSOR_BIN must fail immediately");
2120 assert!(
2121 error.to_string().contains("KRANZ_CURSOR_BIN"),
2122 "expected the error to name the exclusive override, got: {error}"
2123 );
2124 assert!(
2125 !error.to_string().contains("working-agent"),
2126 "the exclusive override must not fall through to `configured`, got: {error}"
2127 );
2128 }
2129
2130 #[test]
2131 fn backend_cursor_parse_fixture() {
2132 let mut events: Vec<AgentEvent> = Vec::new();
2133 for line in fixture_lines() {
2134 events.extend(parse_cursor_line(&line, TEST_MODEL));
2135 }
2136
2137 assert!(
2138 events.iter().any(
2139 |e| matches!(e, AgentEvent::Init { session_id, model, .. }
2140 if !session_id.is_empty() && model == "GPT-5.6 Luna 272K Low")
2141 ),
2142 "expected an Init event with a non-empty session id and the wire's model display string"
2143 );
2144 for kind in ["shellToolCall", "editToolCall", "readToolCall"] {
2145 assert!(
2146 events
2147 .iter()
2148 .any(|e| matches!(e, AgentEvent::ToolUse { tool, .. } if tool == kind)),
2149 "expected a ToolUse event with tool == {kind:?}"
2150 );
2151 assert!(
2152 events
2153 .iter()
2154 .any(|e| matches!(e, AgentEvent::ToolResult { tool, denied, .. }
2155 if tool.as_deref() == Some(kind) && !denied)),
2156 "expected a non-denied ToolResult event with tool == {kind:?}"
2157 );
2158 }
2159 assert!(
2160 events
2161 .iter()
2162 .any(|e| matches!(e, AgentEvent::Text { text, .. } if !text.is_empty())),
2163 "expected at least one Text event"
2164 );
2165
2166 let terminal = events
2167 .iter()
2168 .find_map(|e| match e {
2169 AgentEvent::Result {
2170 text,
2171 is_error,
2172 usage,
2173 cost_usd,
2174 num_turns,
2175 ..
2176 } => Some((text, is_error, usage, cost_usd, num_turns)),
2177 _ => None,
2178 })
2179 .expect("expected a terminal Result event");
2180 let (text, is_error, usage, cost_usd, num_turns) = terminal;
2181 assert!(
2182 !text.is_empty(),
2183 "the terminal result event carries the full result text (no stitching needed)"
2184 );
2185 assert!(!is_error);
2186 assert_eq!(
2187 *usage,
2188 TokenUsage {
2189 input: 32473,
2190 output: 305,
2191 cache_read: 96675,
2192 cache_write: 0,
2193 },
2194 "the fixture's usage object must map verbatim onto TokenUsage"
2195 );
2196 assert!(
2197 cost_usd.is_some(),
2198 "usage is on the wire, so a client-side computed cost must be present"
2199 );
2200 assert_eq!(*num_turns, Some(1));
2201 }
2202
2203 #[test]
2204 fn build_args_maps_read_only_to_mode_ask_and_writable_to_force() {
2205 let read_only = build_args(&spec(Path::new("/tmp/ws"), false));
2206 assert_eq!(
2207 read_only,
2208 vec![
2209 "--print",
2210 "--output-format",
2211 "stream-json",
2212 "--trust",
2213 "--workspace",
2214 "/tmp/ws",
2215 "--model",
2216 TEST_MODEL,
2217 "--mode",
2218 "ask",
2219 "do the thing",
2220 ]
2221 );
2222 let writable = build_args(&spec(Path::new("/tmp/ws"), true));
2223 assert_eq!(
2224 writable,
2225 vec![
2226 "--print",
2227 "--output-format",
2228 "stream-json",
2229 "--trust",
2230 "--workspace",
2231 "/tmp/ws",
2232 "--model",
2233 TEST_MODEL,
2234 "--force",
2235 "do the thing",
2236 ]
2237 );
2238 }
2239
2240 #[test]
2241 fn build_args_ignores_claude_only_fields_and_folds_the_system_prompt() {
2242 let mut session_spec = spec(Path::new("."), false);
2243 session_spec.append_system_prompt = Some("be terse".to_string());
2244 session_spec.permission_mode = Some("acceptEdits".to_string());
2245 session_spec.allowed_tools = vec!["Bash(npm test*)".to_string()];
2246 session_spec.disallowed_tools = vec!["Bash(git push*)".to_string()];
2247 session_spec.tools = vec!["Bash".to_string()];
2248 session_spec.settings_json = Some(json!({"hooks": {}}));
2249 session_spec.json_schema = Some(json!({"type": "object"}));
2250 session_spec.max_budget_usd = Some(5.0);
2251
2252 let args = build_args(&session_spec);
2253 assert_eq!(
2254 args.last().map(String::as_str),
2255 Some("be terse\n\ndo the thing")
2256 );
2257 for forbidden in [
2258 "--effort",
2259 "--permission-mode",
2260 "--allowedTools",
2261 "--disallowedTools",
2262 "--tools",
2263 "--settings",
2264 "--json-schema",
2265 "--max-budget-usd",
2266 "--sandbox",
2267 "--worktree",
2268 "--yolo",
2269 ] {
2270 assert!(
2271 !args.iter().any(|a| a == forbidden),
2272 "argv must not contain {forbidden}: {args:?}"
2273 );
2274 }
2275 }
2276
2277 #[test]
2280 fn tool_result_failure_is_a_normal_failure_not_a_denial() {
2281 let completed = json!({
2282 "type": "tool_call",
2283 "subtype": "completed",
2284 "call_id": "c1",
2285 "tool_call": {
2286 "shellToolCall": {
2287 "args": {"command": "git push origin main"},
2288 "result": {"failure": {
2289 "command": "git push origin main",
2290 "exitCode": 1,
2291 "signal": "",
2292 "stdout": "",
2293 "stderr": "denied by policy",
2294 "aborted": false
2295 }}
2296 }
2297 }
2298 });
2299 let events = parse_cursor_value(completed, TEST_MODEL);
2300 match &events[0] {
2301 AgentEvent::ToolResult {
2302 tool,
2303 denied,
2304 summary,
2305 ..
2306 } => {
2307 assert_eq!(tool.as_deref(), Some("shellToolCall"));
2308 assert!(
2309 !denied,
2310 "a failed command with a real exit code is not a denial"
2311 );
2312 assert_eq!(summary, "denied by policy");
2313 }
2314 other => panic!("expected ToolResult, got {other:?}"),
2315 }
2316 }
2317
2318 #[test]
2322 fn result_without_usage_keeps_usage_and_cost_absent() {
2323 let result = json!({
2324 "type": "result",
2325 "subtype": "success",
2326 "duration_ms": 10,
2327 "is_error": false,
2328 "result": "done",
2329 });
2330 let events = parse_cursor_value(result, TEST_MODEL);
2331 match &events[0] {
2332 AgentEvent::Result {
2333 usage, cost_usd, ..
2334 } => {
2335 assert_eq!(*usage, TokenUsage::default(), "usage is never fabricated");
2336 assert_eq!(*cost_usd, None, "unreported usage means no cost either");
2337 }
2338 other => panic!("expected Result, got {other:?}"),
2339 }
2340 }
2341
2342 #[test]
2345 fn unparseable_lines_become_other_transcript_entries() {
2346 let events = parse_cursor_line("{\"type\":\"resu", TEST_MODEL);
2347 assert_eq!(events.len(), 1);
2348 match &events[0] {
2349 AgentEvent::Other { raw } => {
2350 assert_eq!(raw["unparsed"], "{\"type\":\"resu");
2351 }
2352 other => panic!("expected Other, got {other:?}"),
2353 }
2354 }
2355
2356 #[test]
2359 fn pre_billing_failure_detection_names_only_known_rejections() {
2360 assert!(names_pre_billing_failure(
2361 "Cannot use this model: bogus-id. Available models: gpt-5"
2362 ));
2363 assert!(names_pre_billing_failure("Authentication required"));
2364 assert!(!names_pre_billing_failure("README.md"));
2365 assert!(!names_pre_billing_failure(""));
2366 }
2367
2368 #[test]
2373 fn pre_billing_match_ignores_quoted_phrases_and_torn_json() {
2374 assert!(!names_pre_billing_failure(
2376 "{\"type\":\"assistant\",\"message\":{\"content\":[{\"text\":\"the remote said Authentication required\""
2377 ));
2378 assert!(!names_pre_billing_failure(
2380 "remote: Authentication required"
2381 ));
2382 assert!(!names_pre_billing_failure(
2383 "exit 1 upstream: Cannot use this model: gpt-5"
2384 ));
2385 assert!(names_pre_billing_failure(
2388 "Cannot use this model: bogus-id. Available models: gpt-5"
2389 ));
2390 assert!(names_pre_billing_failure(" authentication required"));
2391 }
2392}