use crate::backend::{
AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
};
#[cfg(unix)]
use crate::backend_claude::kill_group;
#[cfg(windows)]
use crate::backend_claude::win_job;
use crate::cost;
use crate::error::{EngineError, Result};
use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
use crate::types::TokenUsage;
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::process::{Child, ChildStdout};
use tokio::task::JoinHandle;
const SUMMARY_MAX_CHARS: usize = 200;
const STDERR_TAIL_CHARS: usize = 500;
const CURSOR_AUTH_ENV: &str = "CURSOR_API_KEY";
const CURSOR_SEED_ENTRIES: &[&str] = &["cli-config.json", "agent-cli-state.json"];
const PRE_BILLING_FAILURE_PHRASES: &[&str] = &["cannot use this model", "authentication required"];
#[cfg(target_os = "macos")]
const SESSION_KEYCHAIN_LOCK_SECS: u32 = 8 * 60 * 60;
#[cfg(target_os = "macos")]
const SESSION_KEYCHAIN_DB: &str = "kranz-session.keychain-db";
#[cfg(target_os = "macos")]
static SESSION_KEYCHAIN_OPERATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(target_os = "macos")]
fn session_keychain_secret_path(home: &Path) -> PathBuf {
home.join("Library")
.join("Keychains")
.join(".login.keychain-passphrase")
}
#[cfg(target_os = "macos")]
fn security_in_session_home(home: &Path, args: &[&std::ffi::OsStr]) -> std::io::Result<bool> {
let output = security_bounded(home, args, None)?;
Ok(output.status.success())
}
#[cfg(target_os = "macos")]
const SECURITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg(target_os = "macos")]
fn security_bounded(
home: &Path,
args: &[&std::ffi::OsStr],
stdin_script: Option<&str>,
) -> std::io::Result<std::process::Output> {
security_bounded_with_timeout(
Path::new("security"),
home,
args,
stdin_script,
SECURITY_TIMEOUT,
)
}
#[cfg(target_os = "macos")]
fn security_bounded_with_timeout(
binary: &Path,
home: &Path,
args: &[&std::ffi::OsStr],
stdin_script: Option<&str>,
timeout: std::time::Duration,
) -> std::io::Result<std::process::Output> {
use std::io::Read as _;
use std::io::Write as _;
let mut cmd = std::process::Command::new(binary);
cmd.args(args)
.env_clear()
.env("HOME", home)
.env("PATH", "/usr/bin:/bin")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if stdin_script.is_some() {
cmd.stdin(std::process::Stdio::piped());
} else {
cmd.stdin(std::process::Stdio::null());
}
if let Ok(user) = std::env::var("USER") {
cmd.env("USER", user);
}
let mut child = cmd.spawn()?;
if let Some(script) = stdin_script {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(script.as_bytes());
}
}
let deadline = std::time::Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"security did not exit within {}s (killed; locked keychain?)",
timeout.as_secs()
),
));
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
}
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_end(&mut stdout);
}
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_end(&mut stderr);
}
Ok(std::process::Output {
status,
stdout,
stderr,
})
}
#[cfg(target_os = "macos")]
fn security_script_in_session_home(
home: &Path,
script: &str,
) -> std::io::Result<std::process::Output> {
security_bounded(home, &[std::ffi::OsStr::new("-i")], Some(script))
}
#[cfg(target_os = "macos")]
fn write_session_keychain_secret(path: &Path, secret: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
use std::os::unix::fs::PermissionsExt as _;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
file.write_all(secret.as_bytes())?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
}
#[cfg(target_os = "macos")]
fn read_session_keychain_secret(path: &Path) -> std::io::Result<Option<String>> {
use std::io::Read as _;
use std::os::unix::fs::OpenOptionsExt as _;
let file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let mut contents = String::new();
if file.metadata()?.is_file() {
file.take(129).read_to_string(&mut contents)?;
}
if contents.len() != 32 || !contents.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid session keychain secret",
));
}
Ok(Some(contents))
}
#[cfg(target_os = "macos")]
fn ensure_session_login_keychain(home: &Path, session_id: &str) -> bool {
let _operation = SESSION_KEYCHAIN_OPERATION_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let keychains = home.join("Library").join("Keychains");
let normalized = crate::sandbox::absolutize(&keychains);
let Some(operator) = crate::agent_env::os_account_home() else {
tracing::warn!(
"cursor session keychain seed: cannot identify the operator's keychain directory"
);
return false;
};
if normalized != crate::sandbox::absolutize(home).join("Library/Keychains")
|| normalized.starts_with(crate::sandbox::absolutize(
&operator.join("Library/Keychains"),
))
{
tracing::warn!(
"cursor session keychain seed: refusing an operator or redirected keychain directory"
);
return false;
}
let db = keychains.join(SESSION_KEYCHAIN_DB);
let login = keychains.join("login.keychain-db");
let managed_exists = match std::fs::symlink_metadata(&db) {
Ok(metadata) if metadata.is_file() => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
_ => return false,
};
let migrate_login = match std::fs::symlink_metadata(&login) {
Ok(metadata) if metadata.is_file() && !managed_exists => true,
Ok(metadata)
if metadata.file_type().is_symlink()
&& std::fs::read_link(&login)
.is_ok_and(|target| target == Path::new(SESSION_KEYCHAIN_DB)) =>
{
false
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
_ => return false,
};
let db_exists = managed_exists || migrate_login;
if let Err(e) = std::fs::create_dir_all(&keychains) {
tracing::warn!(
error = %e,
"cursor session keychain seed: cannot create Library/Keychains; the CLI may \
fail startup with a security error under the relocated HOME"
);
return false;
}
let secret_path = session_keychain_secret_path(home);
let stored = match read_session_keychain_secret(&secret_path) {
Ok(stored) => stored,
Err(_) => {
tracing::warn!(
"cursor session keychain seed: refusing invalid or linked passphrase material"
);
return false;
}
};
let passphrase = match (stored, db_exists) {
(Some(secret), _) => secret,
(None, true)
if !session_id.is_empty()
&& session_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte)) =>
{
format!("kranz-scratch-{session_id}")
}
(None, true) => return false,
(None, false) => {
let fresh = uuid::Uuid::new_v4().simple().to_string();
if let Err(e) = write_session_keychain_secret(&secret_path, &fresh) {
tracing::warn!(
error = %e,
"cursor session keychain seed: cannot persist the passphrase; the CLI may \
fail startup with a security error under the relocated HOME"
);
return false;
}
fresh
}
};
let Some(db_text) = db
.to_str()
.filter(|path| !path.chars().any(char::is_control))
else {
return false;
};
let db_text = db_text.replace('\\', "\\\\").replace('"', "\\\"");
if migrate_login && std::fs::rename(&login, &db).is_err() {
return false;
}
let restore_legacy = || {
if migrate_login && std::fs::symlink_metadata(&login).is_err() {
let _ = std::fs::rename(&db, &login);
}
};
let mut script = String::new();
if !db_exists {
script.push_str(&format!(
"create-keychain -p {passphrase} \"{}\"\n",
db_text
));
}
script.push_str(&format!(
"unlock-keychain -p {passphrase} \"{}\"\n",
db_text
));
match security_script_in_session_home(home, &script) {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr)
.replace(&passphrase, "<redacted>")
.trim()
.to_string();
tracing::warn!(
status = %output.status,
stderr = %stderr,
"cursor session keychain seed: unlock failed; the CLI may fail startup with \
a security error under the relocated HOME"
);
restore_legacy();
return false;
}
Err(e) => {
tracing::warn!(
error = %e,
"cursor session keychain seed: security failed to spawn; the CLI may fail \
startup with a security error under the relocated HOME"
);
restore_legacy();
return false;
}
}
if std::fs::symlink_metadata(&login).is_err()
&& std::os::unix::fs::symlink(SESSION_KEYCHAIN_DB, &login).is_err()
{
restore_legacy();
return false;
}
let settings = format!(
"set-keychain-settings -lut {SESSION_KEYCHAIN_LOCK_SECS} \"{}\"\n",
db_text
);
match security_script_in_session_home(home, &settings) {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr)
.replace(&passphrase, "<redacted>")
.trim()
.to_string();
tracing::warn!(
status = %output.status,
stderr = %stderr,
"cursor session keychain seed: could not bound the auto-lock; the store keeps \
its current lock settings"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"cursor session keychain seed: could not bound the auto-lock; the store keeps \
its current lock settings"
);
}
}
true
}
#[cfg(target_os = "macos")]
fn lock_session_login_keychain(home: &Path) -> std::io::Result<bool> {
let _operation = SESSION_KEYCHAIN_OPERATION_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let db = home
.join("Library")
.join("Keychains")
.join(SESSION_KEYCHAIN_DB);
if !std::fs::symlink_metadata(&db).is_ok_and(|metadata| metadata.is_file()) {
return Ok(false);
}
security_in_session_home(
home,
&[std::ffi::OsStr::new("lock-keychain"), db.as_os_str()],
)
}
fn cursor_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
if spec.env.contains_key("HOME") {
#[cfg(target_os = "macos")]
if let Some(home) = spec.env.get("HOME") {
let _ = ensure_session_login_keychain(Path::new(home), &spec.session_id);
}
return crate::agent_env::agent_session_env(
&spec.env,
&spec.session_id,
Some(CURSOR_AUTH_ENV),
);
}
let real_home = std::env::var_os("HOME").map(PathBuf::from);
let scratch_root = crate::backend_claude::scratch_home_root(&spec.session_id);
match seed_cursor_scratch_home(&scratch_root, real_home.as_deref()) {
Ok(home) => {
#[cfg(target_os = "macos")]
let _ = ensure_session_login_keychain(&home, &spec.session_id);
tracing::info!(
session_id = %spec.session_id,
decision = "scratch-seeded",
"session spec carried no relocated HOME; spawning into a seeded scratch \
HOME (.cursor minimal account/config set)"
);
crate::agent_env::session_env_with_home(
&spec.env,
&spec.session_id,
Some(CURSOR_AUTH_ENV),
&home,
)
}
Err(e) => {
tracing::warn!(
session_id = %spec.session_id,
error = %e,
"cursor scratch HOME seeding failed; session spawns into an empty scratch \
HOME and will fail auth loudly if CURSOR_API_KEY is not injected"
);
crate::agent_env::agent_session_env(&spec.env, &spec.session_id, Some(CURSOR_AUTH_ENV))
}
}
}
fn seed_cursor_scratch_home(
scratch_root: &Path,
real_home: Option<&Path>,
) -> std::io::Result<PathBuf> {
let home = scratch_root.join("home");
let cursor_dir = home.join(".cursor");
std::fs::create_dir_all(&cursor_dir)?;
if let Some(real_home) = real_home {
let source = real_home.join(".cursor");
for entry in CURSOR_SEED_ENTRIES {
let src = source.join(entry);
let dst = cursor_dir.join(entry);
if src.is_file() {
std::fs::copy(&src, &dst)?;
} else if src.is_dir() {
copy_dir_recursive(&src, &dst)?;
}
}
}
Ok(home)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let target = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &target)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
fn cursor_session_home(spec: &SessionSpec) -> PathBuf {
if let Some(home) = spec.env.get("HOME") {
return PathBuf::from(home);
}
crate::backend_claude::scratch_home_root(&spec.session_id).join("home")
}
#[cfg(test)]
static CURSOR_ENV_LOCK: Mutex<()> = Mutex::new(());
pub fn discover_cursor_binary(configured: Option<&str>) -> Result<PathBuf> {
if let Some(env_bin) = std::env::var_os("KRANZ_CURSOR_BIN") {
if !env_bin.is_empty() {
let candidate = PathBuf::from(env_bin);
return match probe_version(&candidate) {
Ok(_version) => Ok(candidate),
Err(why) => Err(EngineError::Config(format!(
"KRANZ_CURSOR_BIN points at {} which did not work: {why}",
candidate.display()
))),
};
}
}
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(configured) = configured {
candidates.push(PathBuf::from(configured));
}
candidates.push(PathBuf::from("agent"));
#[cfg(windows)]
{
candidates.push(PathBuf::from("agent.cmd"));
candidates.push(PathBuf::from("agent.exe"));
}
candidates.extend(fallback_candidates());
let mut deduped: Vec<PathBuf> = Vec::new();
for candidate in candidates {
if !deduped.contains(&candidate) {
deduped.push(candidate);
}
}
let mut attempts: Vec<String> = Vec::new();
for candidate in deduped {
match probe_version(&candidate) {
Ok(_version) => return Ok(candidate),
Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
}
}
Err(EngineError::Config(format!(
"no working cursor agent binary found; tried: {}. Install the Cursor \
CLI or point kranz at it via the KRANZ_CURSOR_BIN environment variable.",
attempts.join(", ")
)))
}
#[cfg(not(windows))]
fn fallback_candidates() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut out = Vec::new();
if let Some(home) = &home {
out.push(home.join(".npm-global").join("bin").join("agent"));
}
out.push(PathBuf::from("/opt/homebrew/bin/agent"));
out.push(PathBuf::from("/usr/local/bin/agent"));
if let Some(home) = &home {
out.push(home.join(".local").join("bin").join("agent"));
}
out
}
#[cfg(windows)]
fn fallback_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
for dir in [
profile.join("AppData").join("Roaming").join("npm"),
profile.join(".npm-global").join("bin"),
profile.join(".local").join("bin"),
] {
for name in ["agent.cmd", "agent.exe", "agent"] {
out.push(dir.join(name));
}
}
}
out
}
const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
fn probe_version(binary: &Path) -> std::result::Result<String, String> {
crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
}
fn effective_prompt(spec: &SessionSpec) -> String {
let prompt_text = match &spec.prompt {
PromptMode::SingleShot(text) => text.as_str(),
PromptMode::Streaming(text) => text.as_str(),
};
match &spec.append_system_prompt {
Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
_ => prompt_text.to_string(),
}
}
pub fn build_args(spec: &SessionSpec) -> Vec<String> {
let mut args = vec![
"--print".into(),
"--output-format".into(),
"stream-json".into(),
"--trust".into(),
"--workspace".into(),
spec.cwd.display().to_string(),
"--model".into(),
spec.model.clone(),
];
if spec.writable {
args.push("--force".into());
} else {
args.push("--mode".into());
args.push("ask".into());
}
args.push(effective_prompt(spec));
args
}
pub fn parse_cursor_line(line: &str, model: &str) -> Vec<AgentEvent> {
match serde_json::from_str::<Value>(line) {
Ok(value) => parse_cursor_value(value, model),
Err(_) => vec![AgentEvent::Other {
raw: json!({ "unparsed": line }),
}],
}
}
pub fn parse_cursor_value(value: Value, model: &str) -> Vec<AgentEvent> {
let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
match line_type {
"system" if str_field(&value, "subtype") == "init" => vec![AgentEvent::Init {
session_id: str_field(&value, "session_id"),
model: value
.get("model")
.and_then(Value::as_str)
.unwrap_or(model)
.to_string(),
raw: value,
}],
"user" | "system" => vec![AgentEvent::Other { raw: value }],
"assistant" => {
let text = assistant_text(&value);
if text.is_empty() {
vec![AgentEvent::Other { raw: value }]
} else {
vec![AgentEvent::Text { text, raw: value }]
}
}
"tool_call" => match str_field(&value, "subtype").as_str() {
"started" => vec![parse_tool_use(value)],
"completed" => parse_tool_result(value),
_ => vec![AgentEvent::Other { raw: value }],
},
"result" => vec![parse_terminal(value, model)],
_ => vec![AgentEvent::Other { raw: value }],
}
}
fn assistant_text(value: &Value) -> String {
let mut out = String::new();
if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
for block in blocks {
if block.get("type").and_then(Value::as_str) == Some("text") {
if let Some(text) = block.get("text").and_then(Value::as_str) {
out.push_str(text);
}
}
}
}
out
}
fn tool_kind(value: &Value) -> String {
value
.get("tool_call")
.and_then(Value::as_object)
.and_then(|obj| obj.keys().find(|k| k.ends_with("ToolCall")).cloned())
.unwrap_or_else(|| "tool".to_string())
}
fn parse_tool_use(value: Value) -> AgentEvent {
let kind = tool_kind(&value);
let args = value.pointer(&format!("/tool_call/{kind}/args"));
let summary = args
.and_then(|args| {
args.get("command")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
})
.or_else(|| {
value
.pointer(&format!("/tool_call/{kind}/description"))
.and_then(Value::as_str)
})
.unwrap_or("");
AgentEvent::ToolUse {
tool: kind,
summary: truncate_chars(summary, SUMMARY_MAX_CHARS),
raw: value,
}
}
fn parse_tool_result(value: Value) -> Vec<AgentEvent> {
let kind = tool_kind(&value);
let result = value.pointer(&format!("/tool_call/{kind}/result"));
let Some(result) = result else {
return vec![AgentEvent::Other { raw: value }];
};
let summary = if let Some(success) = result.get("success") {
success
.get("stdout")
.or_else(|| success.get("message"))
.or_else(|| success.get("content"))
.or_else(|| success.get("diffString"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| success.to_string())
} else if let Some(failure) = result.get("failure") {
failure
.get("stderr")
.or_else(|| failure.get("stdout"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| {
failure
.get("exitCode")
.and_then(Value::as_i64)
.map(|code| format!("exit code {code}"))
})
.unwrap_or_else(|| failure.to_string())
} else {
return vec![AgentEvent::Other { raw: value }];
};
vec![AgentEvent::ToolResult {
tool: Some(kind),
denied: false,
summary: truncate_chars(&summary, SUMMARY_MAX_CHARS),
raw: value,
}]
}
fn parse_terminal(value: Value, model: &str) -> AgentEvent {
let usage_present = value.get("usage").is_some();
let usage_field = |key: &str| {
value
.pointer(&format!("/usage/{key}"))
.and_then(Value::as_u64)
.unwrap_or(0)
};
let usage = TokenUsage {
input: usage_field("inputTokens"),
output: usage_field("outputTokens"),
cache_read: usage_field("cacheReadTokens"),
cache_write: usage_field("cacheWriteTokens"),
};
let cost_usd = usage_present.then(|| cost::usage_cost_usd(&usage, model));
let is_error = value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false)
|| str_field(&value, "subtype") == "error";
AgentEvent::Result {
text: str_field(&value, "result"),
is_error,
usage,
cost_usd,
num_turns: Some(1),
raw: value,
}
}
fn names_pre_billing_failure(line: &str) -> bool {
let lower = line.trim_start().to_ascii_lowercase();
PRE_BILLING_FAILURE_PHRASES
.iter()
.any(|phrase| lower.starts_with(phrase))
}
fn str_field(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
text.chars().take(max).collect()
}
}
fn last_chars(text: &str, max: usize) -> String {
let chars: Vec<char> = text.chars().collect();
let start = chars.len().saturating_sub(max);
chars[start..].iter().collect()
}
#[derive(Debug, Clone)]
pub struct CursorBackend {
binary: PathBuf,
}
impl CursorBackend {
pub fn new(binary: impl Into<PathBuf>) -> Self {
CursorBackend {
binary: binary.into(),
}
}
pub fn discover(configured: Option<&str>) -> Result<Self> {
Ok(CursorBackend {
binary: discover_cursor_binary(configured)?,
})
}
pub fn binary(&self) -> &Path {
&self.binary
}
}
#[async_trait::async_trait]
impl AgentBackend for CursorBackend {
async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
if spec.resume.is_some() {
return Err(EngineError::Backend(
"cursor backend is single-shot only; resume is unsupported".to_string(),
));
}
let model = spec.model.clone();
let args = build_args(&spec);
let child_env = cursor_child_env(&spec);
if let Some(seed) = &spec.hook_status {
if let Err(e) = crate::hook_status::install_cursor_hook_status(
&cursor_session_home(&spec),
seed,
&spec.session_id,
) {
tracing::warn!(
session_id = %spec.session_id,
error = %e,
"hook-status install failed; the session spawns without the lane \
(mission state is unaffected — the lane is observational)"
);
}
}
let mut command = tokio::process::Command::new(&self.binary);
command
.args(&args)
.current_dir(&spec.cwd)
.env_clear()
.envs(child_env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
command.process_group(0);
let mut child = command.spawn().map_err(|e| {
EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
})?;
#[cfg(windows)]
let job = match child.raw_handle() {
Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
Ok(job) => Some(job),
Err(e) => {
tracing::warn!(error = %e, "failed to create Job Object for cursor child; \
tree-kill on abort will be unavailable");
None
}
},
None => None,
};
let stdout = child
.stdout
.take()
.ok_or_else(|| EngineError::Backend("cursor child has no stdout pipe".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| EngineError::Backend("cursor child has no stderr pipe".to_string()))?;
let stderr_buf = Arc::new(Mutex::new(String::new()));
let stderr_task = {
let buf = Arc::clone(&stderr_buf);
tokio::spawn(async move {
let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
*buf.lock().expect("stderr buffer lock") = tail;
})
};
Ok(Box::new(CursorSession {
session_id: spec.session_id.clone(),
model,
#[cfg(target_os = "macos")]
session_home: cursor_session_home(&spec),
child,
#[cfg(windows)]
job,
lines: BoundedLines::new(stdout),
stderr_buf,
stderr_task: Some(stderr_task),
queue: VecDeque::new(),
saw_result: false,
saw_success_result: false,
pre_billing_failure: None,
exit: None,
}))
}
}
pub struct CursorSession {
session_id: String,
model: String,
#[cfg(target_os = "macos")]
session_home: PathBuf,
child: Child,
#[cfg(windows)]
job: Option<win_job::JobHandle>,
lines: BoundedLines<ChildStdout>,
stderr_buf: Arc<Mutex<String>>,
stderr_task: Option<JoinHandle<()>>,
queue: VecDeque<AgentEvent>,
saw_result: bool,
saw_success_result: bool,
pre_billing_failure: Option<String>,
exit: Option<SessionExit>,
}
#[cfg(unix)]
impl Drop for CursorSession {
fn drop(&mut self) {
crate::backend_claude::kill_unreaped_group(&self.child);
}
}
impl CursorSession {
fn observe(&mut self, event: &AgentEvent) {
match event {
AgentEvent::Init { session_id, .. } => {
self.session_id = session_id.clone();
}
AgentEvent::Result { is_error, .. } => {
self.saw_result = true;
if !is_error {
self.saw_success_result = true;
}
}
AgentEvent::Other { raw } if self.pre_billing_failure.is_none() => {
if let Some(line) = raw.get("unparsed").and_then(Value::as_str) {
if names_pre_billing_failure(line) {
self.pre_billing_failure = Some(truncate_chars(line, STDERR_TAIL_CHARS));
}
}
}
_ => {}
}
}
async fn kill_child(&mut self) {
#[cfg(unix)]
{
let pgid = self
.child
.id()
.and_then(|pid| i32::try_from(pid).ok())
.filter(|pid| *pid > 0);
let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
if !group_killed {
let _ = self.child.start_kill();
}
let _ = self.child.wait().await;
if group_killed {
if let Some(pgid) = pgid {
let _ = kill_group(pgid);
}
}
}
#[cfg(windows)]
{
match &self.job {
Some(job) => job.kill(),
None => {
let _ = self.child.start_kill();
}
}
let _ = self.child.wait().await;
}
#[cfg(all(not(unix), not(windows)))]
{
let _ = self.child.start_kill();
let _ = self.child.wait().await;
}
if let Some(task) = self.stderr_task.take() {
let _ = task.await;
}
#[cfg(target_os = "macos")]
let _ = lock_session_login_keychain(&self.session_home);
}
async fn finish_at_eof(&mut self) {
let status = self.child.wait().await;
if let Some(task) = self.stderr_task.take() {
let _ = task.await;
}
#[cfg(target_os = "macos")]
let _ = lock_session_login_keychain(&self.session_home);
let completed = matches!(status, Ok(ref s) if s.success()) && self.saw_result;
let pre_billing = if completed {
None
} else {
self.pre_billing_failure.clone().or_else(|| {
let tail = self.stderr_tail();
tail.lines().any(names_pre_billing_failure).then_some(tail)
})
};
let exit = match (status, pre_billing) {
(Ok(status), Some(detail)) => SessionExit::Failed(format!(
"cursor rejected the session before any billed turn (exit {status}): {detail} — \
fix the configured model id or authenticate the cursor CLI; this is not a \
retryable failure"
)),
(Ok(status), None) if status.success() && self.saw_result => SessionExit::Completed,
(Ok(status), None) => SessionExit::Failed(format!(
"cursor exited with {status}{}; stderr tail: {}",
if self.saw_result {
""
} else {
" without emitting a terminal event"
},
self.stderr_tail(),
)),
(Err(e), _) => SessionExit::Failed(format!(
"failed to reap cursor process: {e}; stderr tail: {}",
self.stderr_tail(),
)),
};
self.exit = Some(exit);
}
fn stderr_tail(&self) -> String {
let captured = self
.stderr_buf
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
}
}
#[async_trait::async_trait]
impl AgentSession for CursorSession {
fn session_id(&self) -> String {
self.session_id.clone()
}
async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
loop {
if let Some(event) = self.queue.pop_front() {
return Ok(Some(event));
}
if self.exit.is_some() {
return Ok(None);
}
let line = match self.lines.next_line().await {
Ok(Some(line)) => line,
Ok(None) => {
self.finish_at_eof().await;
return Ok(None);
}
Err(e) => {
self.kill_child().await;
self.exit = Some(SessionExit::Failed(format!(
"error reading cursor stdout: {e}; stderr tail: {}",
self.stderr_tail(),
)));
return Ok(None);
}
};
if line.trim().is_empty() {
continue;
}
let events = parse_cursor_line(&line, &self.model);
for event in &events {
self.observe(event);
}
self.queue.extend(events);
}
}
async fn send_user_message(&mut self, _text: &str) -> Result<()> {
Err(EngineError::Backend(
"cursor backend is single-shot only; send_user_message is unsupported".to_string(),
))
}
async fn abort(&mut self) -> Result<()> {
let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
self.kill_child().await;
if self.saw_success_result && already_exited {
self.exit = Some(SessionExit::Completed);
} else {
self.exit = Some(SessionExit::Aborted);
}
Ok(())
}
fn exit_status(&self) -> Option<SessionExit> {
self.exit.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_MODEL: &str = "gpt-5";
fn fixture_lines() -> Vec<String> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("docs")
.join("scoping")
.join("cursor-probe-evidence")
.join("fixture-stream-json.jsonl");
std::fs::read_to_string(path)
.expect("read fixture")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.to_string())
.collect()
}
fn spec(cwd: &Path, writable: bool) -> SessionSpec {
SessionSpec {
cwd: cwd.to_path_buf(),
prompt: PromptMode::SingleShot("do the thing".to_string()),
append_system_prompt: None,
model: TEST_MODEL.to_string(),
effort: "high".to_string(),
session_id: "sess-1".to_string(),
resume: None,
permission_mode: None,
allowed_tools: vec![],
disallowed_tools: vec![],
tools: vec![],
writable,
settings_json: None,
json_schema: None,
max_budget_usd: None,
max_turns: None,
env: Default::default(),
sandbox: None,
hook_status: None,
}
}
#[test]
fn seed_cursor_scratch_home_copies_the_minimal_state_set() {
let real_home = tempfile::tempdir().unwrap();
let cursor = real_home.path().join(".cursor");
std::fs::create_dir_all(cursor.join("chats")).unwrap();
std::fs::write(cursor.join("cli-config.json"), "{}").unwrap();
std::fs::write(cursor.join("agent-cli-state.json"), "{}").unwrap();
std::fs::write(cursor.join("chats").join("big.jsonl"), "transcript").unwrap();
std::fs::write(cursor.join("prompt_history.json"), "[]").unwrap();
let scratch = tempfile::tempdir().unwrap();
let home = seed_cursor_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
let seeded = home.join(".cursor");
assert!(seeded.join("cli-config.json").is_file());
assert!(seeded.join("agent-cli-state.json").is_file());
assert!(
!seeded.join("chats").exists(),
"per-session transcripts are never seeded"
);
assert!(
!seeded.join("prompt_history.json").exists(),
"unbounded history is never seeded"
);
}
#[test]
fn seed_cursor_scratch_home_without_a_source_yields_an_empty_seed() {
let real_home = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let home = seed_cursor_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
let seeded = home.join(".cursor");
assert!(seeded.is_dir());
assert_eq!(std::fs::read_dir(&seeded).unwrap().count(), 0);
}
#[test]
fn cursor_child_env_without_relocated_home_seeds_cursor_config() {
let real_home = tempfile::tempdir().unwrap();
let cursor = real_home.path().join(".cursor");
std::fs::create_dir_all(&cursor).unwrap();
std::fs::write(cursor.join("cli-config.json"), "{}").unwrap();
std::fs::write(cursor.join("agent-cli-state.json"), "{}").unwrap();
let _home_guard =
crate::agent_env::EnvTestGuard::engage(&[("HOME", real_home.path().to_str().unwrap())]);
let session_spec = spec(Path::new("."), false);
let env = cursor_child_env(&session_spec);
let home = env.get("HOME").expect("child env carries HOME");
let seeded = Path::new(home).join(".cursor");
assert!(
seeded.join("cli-config.json").is_file(),
"validator-path HOME must carry the seeded cli-config.json"
);
assert!(
seeded.join("agent-cli-state.json").is_file(),
"validator-path HOME must carry the seeded agent-cli-state.json"
);
}
#[cfg(target_os = "macos")]
#[test]
fn security_bounded_kills_a_locked_keychain_hang_at_the_deadline() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("hung-security");
std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let home = tempfile::tempdir().unwrap();
let start = std::time::Instant::now();
let result = security_bounded_with_timeout(
&stub,
home.path(),
&[std::ffi::OsStr::new("find-generic-password")],
None,
std::time::Duration::from_secs(1),
);
let error = result.expect_err("a hung security must be reported as timed out");
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut, "{error}");
assert!(
error.to_string().contains("did not exit within 1s"),
"the error names the bound: {error}"
);
assert!(
start.elapsed() < std::time::Duration::from_secs(10),
"killed at the deadline, not after the stub's 30s sleep"
);
}
#[cfg(target_os = "macos")]
fn keychain_can_be_created() -> bool {
let home = tempfile::tempdir().unwrap();
if ensure_session_login_keychain(home.path(), "capability-probe") {
return true;
}
crate::test_capability::skip(
crate::test_capability::capability::KEYCHAIN,
"security cannot create and unlock a login keychain under a relocated HOME",
);
false
}
#[cfg(target_os = "macos")]
fn security_output(home: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new("security")
.args(args)
.env_clear()
.env("HOME", home)
.env("PATH", "/usr/bin:/bin")
.output()
.unwrap()
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_seeded_empty_when_absent() {
if !keychain_can_be_created() {
return;
}
let home = tempfile::tempdir().unwrap();
assert!(ensure_session_login_keychain(home.path(), "test-session"));
let db = home
.path()
.join("Library")
.join("Keychains")
.join("login.keychain-db");
assert_eq!(
std::fs::read_link(&db).unwrap(),
Path::new(SESSION_KEYCHAIN_DB)
);
let backing = db.parent().unwrap().join(SESSION_KEYCHAIN_DB);
let meta = std::fs::symlink_metadata(&backing).unwrap();
assert!(
meta.is_file(),
"the backing store is a private regular file"
);
assert!(meta.len() > 0, "security create-keychain writes a real db");
assert!(keychain_is_unlocked(&db));
let unlock_material =
std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap();
assert!(!unlock_material.is_empty());
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_never_replaces_an_existing_db() {
if !keychain_can_be_created() {
return;
}
let home = tempfile::tempdir().unwrap();
let keychains = home.path().join("Library").join("Keychains");
std::fs::create_dir_all(&keychains).unwrap();
let db = keychains.join("login.keychain-db");
std::fs::write(&db, b"sentinel").unwrap();
let _ = ensure_session_login_keychain(home.path(), "test-session");
assert_eq!(std::fs::read(&db).unwrap(), b"sentinel");
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_hardened_secret_is_random_per_session_and_stored_0600() {
if !keychain_can_be_created() {
return;
}
use std::os::unix::fs::PermissionsExt as _;
let home_a = tempfile::tempdir().unwrap();
let home_b = tempfile::tempdir().unwrap();
let start = std::sync::Barrier::new(3);
let (seeded_a, seeded_b) = std::thread::scope(|scope| {
let a = scope.spawn(|| {
start.wait();
ensure_session_login_keychain(home_a.path(), "test-session")
});
let b = scope.spawn(|| {
start.wait();
ensure_session_login_keychain(home_b.path(), "test-session")
});
start.wait();
(a.join().unwrap(), b.join().unwrap())
});
assert!(seeded_a);
assert!(seeded_b);
let path_a = session_keychain_secret_path(home_a.path());
let secret_a = std::fs::read_to_string(&path_a).unwrap();
let secret_b =
std::fs::read_to_string(session_keychain_secret_path(home_b.path())).unwrap();
assert_ne!(
secret_a, secret_b,
"each session gets its own random secret"
);
assert!(!secret_a.contains("test-session"));
assert_eq!(secret_a.len(), 32, "a uuid v4 simple secret is 128 bits");
assert!(secret_a.chars().all(|c| c.is_ascii_hexdigit()));
let mode = std::fs::metadata(&path_a).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"the secret file must be owner-only, got {mode:o}"
);
assert!(ensure_session_login_keychain(home_a.path(), "test-session"));
assert_eq!(
std::fs::read_to_string(&path_a).unwrap(),
secret_a,
"a respawn into the same HOME reuses the stored secret"
);
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_hardened_lock_timeout_is_bounded_and_unlocked() {
if !keychain_can_be_created() {
return;
}
let home = tempfile::tempdir().unwrap();
assert!(
ensure_session_login_keychain(home.path(), "test-session"),
"the seed's own unlock witness: batch A exited 0, so the store \
is known-unlocked and show-keychain-info below cannot prompt"
);
let db = home
.path()
.join("Library")
.join("Keychains")
.join("login.keychain-db");
let info = security_output(home.path(), &["show-keychain-info", db.to_str().unwrap()]);
assert!(
info.status.success(),
"show-keychain-info on the known-unlocked db: {}",
String::from_utf8_lossy(&info.stderr)
);
let info_text = format!(
"{}{}",
String::from_utf8_lossy(&info.stdout),
String::from_utf8_lossy(&info.stderr)
);
assert!(
info_text.contains(&format!("timeout={SESSION_KEYCHAIN_LOCK_SECS}s")),
"the auto-lock is bounded, never no-timeout: {info_text}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_hardened_teardown_relocks_the_store() {
if !keychain_can_be_created() {
return;
}
let home = tempfile::tempdir().unwrap();
assert!(ensure_session_login_keychain(home.path(), "test-session"));
let backing = home
.path()
.join("Library/Keychains")
.join(SESSION_KEYCHAIN_DB);
assert!(keychain_is_unlocked(&backing));
let ran = lock_session_login_keychain(home.path()).unwrap();
assert!(ran, "the teardown hook ran lock-keychain on the session db");
assert!(
!keychain_is_unlocked(&backing),
"teardown left the store unlocked"
);
let again = lock_session_login_keychain(home.path()).unwrap();
assert!(
again,
"relocking an already-locked db neither prompts nor errors"
);
let db = home
.path()
.join("Library")
.join("Keychains")
.join("login.keychain-db");
let unlock_material =
std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap();
assert!(
security_output(
home.path(),
&[
"unlock-keychain",
"-p",
&unlock_material,
db.to_str().unwrap(),
]
)
.status
.success(),
"the stored secret re-unlocks after teardown"
);
assert!(keychain_is_unlocked(&backing));
}
#[cfg(target_os = "macos")]
fn keychain_is_unlocked(path: &Path) -> bool {
use std::ffi::{c_char, c_void, CString};
#[link(name = "Security", kind = "framework")]
unsafe extern "C" {
fn SecKeychainOpen(path: *const c_char, keychain: *mut *mut c_void) -> i32;
fn SecKeychainGetStatus(keychain: *mut c_void, status: *mut u32) -> i32;
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFRelease(value: *const c_void);
}
let path = CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
let mut keychain = std::ptr::null_mut();
let mut status = 0;
unsafe {
assert_eq!(SecKeychainOpen(path.as_ptr(), &mut keychain), 0);
let result = SecKeychainGetStatus(keychain, &mut status);
CFRelease(keychain);
assert_eq!(result, 0);
}
status & 1 != 0 }
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_refuses_operator_paths_and_injected_secret_scripts() {
if let Some(operator) = crate::agent_env::os_account_home() {
assert!(!ensure_session_login_keychain(&operator, "test-session"));
}
let home = tempfile::tempdir().unwrap();
let keychains = home.path().join("Library/Keychains");
std::fs::create_dir_all(&keychains).unwrap();
let injected = "bad\nlock-keychain\n";
std::fs::write(session_keychain_secret_path(home.path()), injected).unwrap();
assert!(!ensure_session_login_keychain(home.path(), "test-session"));
assert!(!keychains.join(SESSION_KEYCHAIN_DB).exists());
assert_eq!(
std::fs::read_to_string(session_keychain_secret_path(home.path())).unwrap(),
injected
);
}
#[cfg(target_os = "macos")]
#[test]
fn cursor_keychain_hardened_legacy_seed_still_unlocks() {
if !keychain_can_be_created() {
return;
}
let home = tempfile::tempdir().unwrap();
let keychains = home.path().join("Library").join("Keychains");
std::fs::create_dir_all(&keychains).unwrap();
let db = keychains.join("login.keychain-db");
let created = security_output(
home.path(),
&[
"create-keychain",
"-p",
"kranz-scratch-test-session",
db.to_str().unwrap(),
],
);
assert!(created.status.success());
let locked = security_output(home.path(), &["lock-keychain", db.to_str().unwrap()]);
assert!(locked.status.success());
assert!(
ensure_session_login_keychain(home.path(), "test-session"),
"the legacy derived passphrase still unlocks the pre-hardening db"
);
}
#[test]
fn cursor_child_env_injects_the_sanctioned_api_key_and_never_ambient_secrets() {
let _poison = crate::agent_env::EnvTestGuard::engage(&[
("CURSOR_API_KEY", "hunter2"),
("GH_TOKEN", "ghp-poison"),
("SLACK_BOT_TOKEN", "xoxb-poison"),
]);
let session_spec = spec(Path::new("."), false);
let env = cursor_child_env(&session_spec);
assert_eq!(
env.get("CURSOR_API_KEY").map(String::as_str),
Some("hunter2"),
"the sanctioned auth var must be injected explicitly"
);
for secret in ["GH_TOKEN", "SLACK_BOT_TOKEN", "ANTHROPIC_API_KEY"] {
assert!(!env.contains_key(secret), "child env leaked {secret}");
}
}
#[test]
#[cfg(unix)]
fn cursor_probe_version_kills_a_hung_binary_within_the_deadline() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("hung-agent");
std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let start = std::time::Instant::now();
let result = probe_version(&stub);
let error = result.expect_err("a hung probe must be reported as broken");
assert!(error.contains("did not exit"), "{error}");
assert!(
start.elapsed() < std::time::Duration::from_secs(10),
"probe returned within the deadline, not after the stub's sleep"
);
}
#[test]
fn cursor_discovery_honors_env_override_exclusively() {
let _env_lock = crate::agent_env::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _guard = CURSOR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let working = dir.path().join("working-agent");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&working, "#!/bin/sh\necho 2026.07.08-test\n").unwrap();
std::fs::set_permissions(&working, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let bogus = dir.path().join("does-not-exist-agent");
std::env::set_var("KRANZ_CURSOR_BIN", &bogus);
let result = discover_cursor_binary(Some(working.to_str().unwrap()));
std::env::remove_var("KRANZ_CURSOR_BIN");
let error = result.expect_err("a broken KRANZ_CURSOR_BIN must fail immediately");
assert!(
error.to_string().contains("KRANZ_CURSOR_BIN"),
"expected the error to name the exclusive override, got: {error}"
);
assert!(
!error.to_string().contains("working-agent"),
"the exclusive override must not fall through to `configured`, got: {error}"
);
}
#[test]
fn backend_cursor_parse_fixture() {
let mut events: Vec<AgentEvent> = Vec::new();
for line in fixture_lines() {
events.extend(parse_cursor_line(&line, TEST_MODEL));
}
assert!(
events.iter().any(
|e| matches!(e, AgentEvent::Init { session_id, model, .. }
if !session_id.is_empty() && model == "GPT-5.6 Luna 272K Low")
),
"expected an Init event with a non-empty session id and the wire's model display string"
);
for kind in ["shellToolCall", "editToolCall", "readToolCall"] {
assert!(
events
.iter()
.any(|e| matches!(e, AgentEvent::ToolUse { tool, .. } if tool == kind)),
"expected a ToolUse event with tool == {kind:?}"
);
assert!(
events
.iter()
.any(|e| matches!(e, AgentEvent::ToolResult { tool, denied, .. }
if tool.as_deref() == Some(kind) && !denied)),
"expected a non-denied ToolResult event with tool == {kind:?}"
);
}
assert!(
events
.iter()
.any(|e| matches!(e, AgentEvent::Text { text, .. } if !text.is_empty())),
"expected at least one Text event"
);
let terminal = events
.iter()
.find_map(|e| match e {
AgentEvent::Result {
text,
is_error,
usage,
cost_usd,
num_turns,
..
} => Some((text, is_error, usage, cost_usd, num_turns)),
_ => None,
})
.expect("expected a terminal Result event");
let (text, is_error, usage, cost_usd, num_turns) = terminal;
assert!(
!text.is_empty(),
"the terminal result event carries the full result text (no stitching needed)"
);
assert!(!is_error);
assert_eq!(
*usage,
TokenUsage {
input: 32473,
output: 305,
cache_read: 96675,
cache_write: 0,
},
"the fixture's usage object must map verbatim onto TokenUsage"
);
assert!(
cost_usd.is_some(),
"usage is on the wire, so a client-side computed cost must be present"
);
assert_eq!(*num_turns, Some(1));
}
#[test]
fn build_args_maps_read_only_to_mode_ask_and_writable_to_force() {
let read_only = build_args(&spec(Path::new("/tmp/ws"), false));
assert_eq!(
read_only,
vec![
"--print",
"--output-format",
"stream-json",
"--trust",
"--workspace",
"/tmp/ws",
"--model",
TEST_MODEL,
"--mode",
"ask",
"do the thing",
]
);
let writable = build_args(&spec(Path::new("/tmp/ws"), true));
assert_eq!(
writable,
vec![
"--print",
"--output-format",
"stream-json",
"--trust",
"--workspace",
"/tmp/ws",
"--model",
TEST_MODEL,
"--force",
"do the thing",
]
);
}
#[test]
fn build_args_ignores_claude_only_fields_and_folds_the_system_prompt() {
let mut session_spec = spec(Path::new("."), false);
session_spec.append_system_prompt = Some("be terse".to_string());
session_spec.permission_mode = Some("acceptEdits".to_string());
session_spec.allowed_tools = vec!["Bash(npm test*)".to_string()];
session_spec.disallowed_tools = vec!["Bash(git push*)".to_string()];
session_spec.tools = vec!["Bash".to_string()];
session_spec.settings_json = Some(json!({"hooks": {}}));
session_spec.json_schema = Some(json!({"type": "object"}));
session_spec.max_budget_usd = Some(5.0);
let args = build_args(&session_spec);
assert_eq!(
args.last().map(String::as_str),
Some("be terse\n\ndo the thing")
);
for forbidden in [
"--effort",
"--permission-mode",
"--allowedTools",
"--disallowedTools",
"--tools",
"--settings",
"--json-schema",
"--max-budget-usd",
"--sandbox",
"--worktree",
"--yolo",
] {
assert!(
!args.iter().any(|a| a == forbidden),
"argv must not contain {forbidden}: {args:?}"
);
}
}
#[test]
fn tool_result_failure_is_a_normal_failure_not_a_denial() {
let completed = json!({
"type": "tool_call",
"subtype": "completed",
"call_id": "c1",
"tool_call": {
"shellToolCall": {
"args": {"command": "git push origin main"},
"result": {"failure": {
"command": "git push origin main",
"exitCode": 1,
"signal": "",
"stdout": "",
"stderr": "denied by policy",
"aborted": false
}}
}
}
});
let events = parse_cursor_value(completed, TEST_MODEL);
match &events[0] {
AgentEvent::ToolResult {
tool,
denied,
summary,
..
} => {
assert_eq!(tool.as_deref(), Some("shellToolCall"));
assert!(
!denied,
"a failed command with a real exit code is not a denial"
);
assert_eq!(summary, "denied by policy");
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn result_without_usage_keeps_usage_and_cost_absent() {
let result = json!({
"type": "result",
"subtype": "success",
"duration_ms": 10,
"is_error": false,
"result": "done",
});
let events = parse_cursor_value(result, TEST_MODEL);
match &events[0] {
AgentEvent::Result {
usage, cost_usd, ..
} => {
assert_eq!(*usage, TokenUsage::default(), "usage is never fabricated");
assert_eq!(*cost_usd, None, "unreported usage means no cost either");
}
other => panic!("expected Result, got {other:?}"),
}
}
#[test]
fn unparseable_lines_become_other_transcript_entries() {
let events = parse_cursor_line("{\"type\":\"resu", TEST_MODEL);
assert_eq!(events.len(), 1);
match &events[0] {
AgentEvent::Other { raw } => {
assert_eq!(raw["unparsed"], "{\"type\":\"resu");
}
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn pre_billing_failure_detection_names_only_known_rejections() {
assert!(names_pre_billing_failure(
"Cannot use this model: bogus-id. Available models: gpt-5"
));
assert!(names_pre_billing_failure("Authentication required"));
assert!(!names_pre_billing_failure("README.md"));
assert!(!names_pre_billing_failure(""));
}
#[test]
fn pre_billing_match_ignores_quoted_phrases_and_torn_json() {
assert!(!names_pre_billing_failure(
"{\"type\":\"assistant\",\"message\":{\"content\":[{\"text\":\"the remote said Authentication required\""
));
assert!(!names_pre_billing_failure(
"remote: Authentication required"
));
assert!(!names_pre_billing_failure(
"exit 1 upstream: Cannot use this model: gpt-5"
));
assert!(names_pre_billing_failure(
"Cannot use this model: bogus-id. Available models: gpt-5"
));
assert!(names_pre_billing_failure(" authentication required"));
}
}