Skip to main content

kranz_engine/
backend_cursor.rs

1//! Cursor agent backend: drives the Cursor CLI headless
2//! (`agent --print --output-format stream-json`).
3//!
4//! Ground truth is the decided route in `docs/scoping/cursor-cli-backend.md`
5//! (decision revised 2026-07-09: `direct-parser`) and the committed wire
6//! fixture `docs/scoping/cursor-probe-evidence/fixture-stream-json.jsonl`,
7//! captured from a real write-capable run. The parser is built against THAT
8//! shape first; the event mapping table in the scoping doc's implementation
9//! brief is the authority for every arm below.
10//!
11//! This module is single-shot only: `--resume` exists on the CLI but was
12//! never exercised by the probe, and there is no streaming-input mode, so
13//! [`CursorSession::send_user_message`] and a `resume`d [`SessionSpec`] are
14//! both rejected at the seam rather than translated into flags.
15//!
16//! Several `SessionSpec` fields are claude-isms with no cursor equivalent and
17//! are deliberately ignored when building argv: `json_schema`,
18//! `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
19//! `disallowed_tools`, `tools`, `settings_json`, and `effort` — the CLI has
20//! no `--effort` flag, and the `--model` bracket-override syntax
21//! (`'model[effort=high]'`) is documented only for parameterized models and
22//! was never probed, so effort is NOT munged into the model id.
23//!
24//! Permission posture (probe item 6, observed): read-only sessions map to
25//! `--mode ask` (turn-level read-only, the validator role), writable sessions
26//! to default mode + `--force` (writes/shell proceed unprompted, the worker
27//! role; `--yolo` is only a documented alias of `--force`, never separately
28//! live-tested, so `--force` is the emitted spelling). `--trust` rides every
29//! session: headless `--print` otherwise prompts for workspace trust. The
30//! CLI's own `--sandbox` flag is NEVER emitted — the probe observed it make
31//! no difference to outbound network access or writes outside `--workspace`,
32//! so it is not a kranz isolation boundary. The no-push/no-publish/
33//! no-main-write invariants therefore hold exactly the way the scoping doc
34//! prescribes: turn-level read-only modes for validators, throwaway
35//! `--workspace` directories, scoped credentials, and (when requested) the
36//! engine's external process sandbox — never this flag. Because the resolved
37//! OS sandbox is not applied by this backend,
38//! `BackendKind::Cursor::supports_sandbox_enforcement` is `false` and
39//! `config::validate` fails closed on enforced-sandbox pairings.
40//!
41//! Auth posture (probe, verified): the CLI's login state does not survive a
42//! relocated `$HOME` (`HOME=/tmp/x agent status` reports "Not logged in"),
43//! and on macOS the credential itself is Keychain-backed (the sandboxed probe
44//! crashed with `SecItemCopyMatching failed -50`) — no credential FILE exists
45//! under `~/.cursor` to copy. The scratch-HOME seed therefore carries only
46//! the small account-identity/CLI-config files ([`CURSOR_SEED_ENTRIES`]),
47//! never transcripts or caches, and the one ambient var a cursor session may
48//! authenticate with — `CURSOR_API_KEY`, the scoping doc's sanctioned
49//! headless channel — is injected explicitly, never the ambient set. A
50//! session whose seed+key is insufficient fails auth loudly
51//! ("Authentication required", pre-billing), which the stream watcher turns
52//! into an honest configuration-style failure rather than a retryable one
53//! (probe item 5). One macOS addendum found by the first live mission
54//! (m-a5a8fd, 2026-08-08): the CLI consults the login keychain at startup
55//! EVEN with `CURSOR_API_KEY` set, and the keychain domain resolves through
56//! `HOME`, so a relocated HOME without `Library/Keychains/login.keychain-db`
57//! dies pre-auth with `security` exit 154. Both spawn branches therefore
58//! seed an EMPTY login keychain (`ensure_session_login_keychain`) — never
59//! a link to the operator's real keychain.
60//!
61//! Hook-status lane (ticket `agent-hooks-status-signals`,
62//! [`crate::hook_status`]): when the runner seeds
63//! [`SessionSpec::hook_status`] (mission config `hookStatus.enabled` AND
64//! this hook-capable backend), [`CursorBackend::start`] installs the lane
65//! into the session-private HOME BEFORE spawning: `<home>/.cursor/
66//! hooks.json` (the CLI's documented user-level hook file — verified
67//! 2026-08-06 against <https://cursor.com/docs/hooks>: `version: 1` with
68//! per-event `[{command, timeout}]` handlers, payloads delivered on stdin,
69//! exit 0 = ok / 2 = block / other = fail-open; there is NO HTTP hook
70//! type, so delivery to kranz's endpoint is the installed
71//! `kranz hook-status` relay) plus the per-session spec file the relay
72//! reads. The install NEVER touches the workspace's tracked
73//! `.cursor/hooks.json` — the project-level file is the operator's own,
74//! and mutating it as a side effect of spawning would be exactly the
75//! silent tracked-tree write the ticket forbids. Every install failure
76//! degrades to NO lane (loud warning, ordinary session): hooks are
77//! non-authoritative observability, and the lane disabled is the
78//! byte-identical default. `SessionSpec::hook_status` is the one field
79//! this backend consumes beyond argv/env; the claude-ism fields stay
80//! ignored as documented above.
81
82use 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
101/// Max characters kept in tool-use / tool-result summaries.
102const SUMMARY_MAX_CHARS: usize = 200;
103/// Max characters of captured stderr included in failure messages.
104const STDERR_TAIL_CHARS: usize = 500;
105
106/// The ambient var a cursor session may authenticate with (injected
107/// explicitly, never via ambient inheritance). Confirmed by `agent --help`:
108/// `--api-key <key>` "(can also use CURSOR_API_KEY env var)".
109const CURSOR_AUTH_ENV: &str = "CURSOR_API_KEY";
110
111/// The minimal `~/.cursor` state seeded into a session's scratch HOME:
112/// `cli-config.json` carries the account identity (`authInfo`) and CLI
113/// config, `agent-cli-state.json` the CLI's small state file. Both are a few
114/// KB. The interactive-login CREDENTIAL is not here — on macOS it is
115/// Keychain-backed (see module docs) — so this seed preserves account/config
116/// context but cannot guarantee auth; `CURSOR_API_KEY` is the reliable
117/// headless channel. Deliberately excluded: `chats/` (per-session
118/// transcripts, hundreds of MB), `ai-tracking/`, `projects/`, `plugins/`,
119/// `extensions/`, `prompt_history.json`, `statsig-cache.json` (unbounded or
120/// per-session state).
121const CURSOR_SEED_ENTRIES: &[&str] = &["cli-config.json", "agent-cli-state.json"];
122
123/// Plain-text (non-JSON) stdout/stderr phrases the CLI emits when it rejects
124/// a session BEFORE any billed turn starts (probe item 5): an invalid or
125/// unentitled `--model` id exits 1 with `Cannot use this model: <id>.
126/// Available models: ...`, and an unauthenticated `--print` fails with
127/// `Authentication required`. Both are deterministic, user-readable,
128/// pre-billing rejections — the session watcher surfaces them as
129/// configuration-style failures (fix the model id / authenticate), never as
130/// retryable transport errors.
131const PRE_BILLING_FAILURE_PHRASES: &[&str] = &["cannot use this model", "authentication required"];
132
133/// macOS: `agent` consults the login keychain at startup even when
134/// `CURSOR_API_KEY` is set, and the keychain domain resolves through `HOME`
135/// — so a relocated scratch HOME with no `Library/Keychains/login.keychain-db`
136/// dies before auth with `Security command failed: ... code: 154` (verified
137/// live 2026-08-08: scratch HOME + API key fails 154; the same plus an empty
138/// keychain succeeds). Seed an EMPTY keychain so the startup probe has a
139/// valid, secret-free domain. Never link or copy the operator's real login
140/// keychain — that would hand the session every credential reachable in it,
141/// defeating the scratch-HOME posture.
142/// The actual private store is `kranz-session.keychain-db`; a relative
143/// `login.keychain-db` alias stays inside this scratch directory. This avoids
144/// securityd's special login-store handling without exposing operator state.
145///
146/// Two further live findings shape the seed (m-eee81f): an EMPTY-password
147/// keychain cannot be unlocked programmatically, and a fresh keychain
148/// defaults to a 300-second inactivity relock — long builds then relock it
149/// mid-session and every credential write pops a desktop dialog the
150/// operator cancels only to see again. So the seed creates the keychain
151/// with a real passphrase, sets a session-SCALE auto-lock (never the 300s
152/// default, never no-timeout), and unlocks at every spawn (unlock state
153/// lives in securityd, so the engine-side unlock covers the subsequently
154/// spawned CLI).
155///
156/// Non-interactivity invariant (14th-pass review, cargo-test hang): some
157/// `security` subcommands fall back to INTERACTIVE auth — a GUI password
158/// dialog at the operator — when they touch a LOCKED db without a
159/// passphrase (`set-keychain-settings`, `show-keychain-info`); others never
160/// prompt (`create-keychain -p`, `unlock-keychain -p`, `lock-keychain`).
161/// Every call below is therefore either passphrase-carrying or ordered so
162/// it only ever runs against a db this code path just unlocked.
163///
164/// Auto-lock restored on the seeded keychain: session-scale (the fresh-db
165/// 300s default relocked mid-build into desktop prompts — m-eee81f), never
166/// no-timeout; [`lock_session_login_keychain`] relocks at session end.
167#[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/// `securityd` is shared by every session owned by the OS account. Even
174/// keychains at distinct explicit paths can intermittently reject overlapping
175/// create/unlock/settings requests (observed on hosted macOS while the
176/// keychain tests ran in parallel). Keep each setup or teardown transaction
177/// contiguous; this does not serialize the agent sessions themselves.
178#[cfg(target_os = "macos")]
179static SESSION_KEYCHAIN_OPERATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
180
181/// The per-session keychain passphrase file: a dotfile beside the db it
182/// guards, inside the session-private scratch HOME.
183#[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/// Run an argv `security` invocation pinned to the session HOME, returning
191/// whether it exited 0. Only for subcommands that can never fall back to
192/// interactive auth (see the invariant above [`SESSION_KEYCHAIN_LOCK_SECS`])
193/// — currently just `lock-keychain` at session teardown; passphrase-carrying
194/// work goes through [`security_script_in_session_home`].
195///
196/// Bounded by [`SECURITY_TIMEOUT`]: a locked keychain makes `security` park
197/// on a GUI approval forever (observed live 2026-08-10, a 20-minute gate
198/// hang), so every spawn goes through the one bounded helper and a timeout
199/// surfaces as `Err(TimedOut)` — unavailable-not-authorized, never a hang.
200#[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/// Hard ceiling on any `security` invocation: a healthy subcommand answers in
207/// well under a second; anything past the bound is a locked keychain waiting
208/// on a GUI approval that will never come in a headless session.
209#[cfg(target_os = "macos")]
210const SECURITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
211
212/// The one bounded `security` spawn (ticket security-cli-invocation-timeout):
213/// every `security` call in the engine goes through here so a locked keychain
214/// can never park a gate or a spawn. Polls `try_wait` against
215/// [`SECURITY_TIMEOUT`], kills the child on expiry, and reports the timeout
216/// as `Err(TimedOut)` naming the bound. `stdin_script`, when present, is fed
217/// to the child on a pipe (the `security -i` batch form).
218#[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/// [`security_bounded`] with an explicit binary path and timeout — the unit
234/// under test for the locked-keychain hang regression (a stub `security`
235/// that sleeps forever must fail fast, never hang the caller). The production
236/// wrapper pins the binary to `security` resolved through the pinned
237/// `/usr/bin:/bin` PATH; only tests substitute a stub path.
238#[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    // HOME is pinned to the session home so any preference side effect
264    // lands in the scratch tree, never in the operator's real keychain
265    // search list.
266    let mut child = cmd.spawn()?;
267    if let Some(script) = stdin_script {
268        if let Some(mut stdin) = child.stdin.take() {
269            // A broken pipe means the process died before reading; the wait
270            // below surfaces the real status.
271            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    // The process has exited, so both pipes are at EOF and drain immediately.
298    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/// Run `security -i` with `script` fed on stdin: the one-shot commands have
314/// no passphrase-from-stdin form, so interactive mode is the only way to
315/// keep secrets out of argv (see the seed's doc above). stdout is dropped
316/// (interactive mode may echo prompts); stderr is captured for the
317/// failure warning — callers must redact any secret before logging it.
318/// Bounded by [`SECURITY_TIMEOUT`] like every `security` spawn.
319#[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/// Write the per-session keychain passphrase 0600 (mode forced even when
328/// the file pre-exists — `mode()` applies only at creation), refusing a
329/// planted symlink like the repo's other secret-adjacent writes.
330#[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    // This file is worker-writable. Never let arbitrary contents become
364    // commands in the stdin-fed `security -i` protocol on a respawn.
365    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/// Seed/unlock the session's login keychain (see the block doc above
375/// [`SESSION_KEYCHAIN_LOCK_SECS`] for the full rationale). Secret hygiene
376/// (ticket keychain-passphrase-predictable-permanent-unlock — the seeded
377/// store is NOT empty forever; Cursor writes credentials into it, so the v2
378/// shortcuts stopped being free):
379///
380/// - The passphrase is a RANDOM per-session secret (uuid v4, the repo's
381///   randomness idiom) — never derived from the session id, which appears
382///   in paths and logs. It is persisted 0600 next to the db
383///   ([`session_keychain_secret_path`]) so later spawns into the same HOME
384///   re-unlock with the same secret, and it is never logged (the one
385///   captured `security` stderr is redacted before it can reach a warning).
386/// - argv hygiene: `security unlock-keychain` has no stdin/flag passphrase
387///   alternative (without `-p` it prompts via getpass(3) on the controlling
388///   tty, which a headless spawn does not have), so an argv `-p` would
389///   expose the secret to any same-user `ps`. The whole
390///   create/settings/unlock sequence is instead fed to `security -i`
391///   (interactive mode) on a PIPE ([`security_script_in_session_home`]):
392///   argv carries only `-i`, and the pipe contents are not visible to other
393///   processes. Verified live 2026-08-09: a stdin-fed batch behaves
394///   identically to the argv form (including quoted paths with spaces), a
395///   failed command does not abort the batch, and the process exit status
396///   is the LAST command's — so with `unlock-keychain` last, a non-zero
397///   exit means the unlock failed. Residual exposure: the secret lives in
398///   securityd's memory and in the 0600 file inside the session-private
399///   HOME — both reachable only to the same user, which the scratch-HOME
400///   threat model already accepts (the session itself runs with that HOME).
401/// - The store must not stay open forever: the seed restores a bounded
402///   auto-lock (`set-keychain-settings -lut
403///   [`SESSION_KEYCHAIN_LOCK_SECS`]`) and [`lock_session_login_keychain`]
404///   relocks it when the session ends.
405/// - GUI-prompt hygiene (the non-interactivity invariant above
406///   [`SESSION_KEYCHAIN_LOCK_SECS`]): `set-keychain-settings` on a LOCKED
407///   db falls back to interactive auth — a desktop password dialog — so it
408///   runs ONLY in batch B, after batch A's `unlock-keychain` reported
409///   success and the db is known-unlocked. A failed unlock skips the
410///   settings entirely: no call here can ever pop a prompt at the
411///   operator, even on a respawn into a scratch HOME this module's own
412///   teardown just relocked.
413///
414/// Unlock the private backing store, migrating legacy login-named stores
415/// without replacing their contents. Failed legacy unlocks restore the path.
416#[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    // Refuse foreign aliases and special files. Legacy regular login stores
442    // are moved without changing their contents, then get our local alias.
443    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        // The stored secret wins: later spawns into the same HOME re-unlock
481        // with the passphrase the db was created with.
482        (Some(secret), _) => secret,
483        // Pre-hardening seeds (acdc77b) derived the passphrase from the
484        // session id and left no secret file; keep unlocking those homes so
485        // a scratch HOME written before the upgrade never wedges.
486        (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    // Batch A carries the passphrase (stdin script, never argv): create
524    // (fresh db only), then unlock LAST — the batch's exit status is the
525    // last command's, so success means the db is now unlocked. Paths are
526    // quoted — verified handled by interactive mode.
527    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            // The passphrase is never logged: redact it from the captured
542            // stderr before it can reach a warning (a future `security`
543            // build that echoes its input would otherwise leak it).
544            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            // The db may still be LOCKED — applying settings now would fall
555            // back to an interactive GUI prompt (the failure this ordering
556            // exists to prevent). Skip batch B.
557            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    // Batch B: the db is known-unlocked (batch A just succeeded), so
577    // bounding the auto-lock cannot prompt.
578    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    // Batch A's unlock reported success: the db is left unlocked.
605    true
606}
607
608/// Relock the seeded keychain at session end so the store is not left open
609/// past the session's life — the auto-lock timeout is only the backstop.
610/// `lock-keychain` takes only the db path and LOCKING never requires
611/// authorization, so this cannot fall back to an interactive prompt even
612/// on an already-locked db (it just exits non-zero); fire-and-forget at
613/// teardown, the status is returned only so tests can assert the command
614/// ran. Best-effort like the seed: a failure leaves the timeout.
615#[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
633/// The cleared environment one `agent` session spawns with, mirroring
634/// [`crate::backend_kimi`]'s seeding contract: a spec carrying a relocated
635/// scratch `HOME` (worker relocation) is used verbatim; otherwise a fresh
636/// per-session scratch HOME is seeded with [`CURSOR_SEED_ENTRIES`] so the
637/// CLI's account-identity/config context survives. Seeding failure degrades
638/// to an empty scratch home — the session then fails auth loudly rather than
639/// silently inheriting the operator's real HOME. `CURSOR_API_KEY` is injected
640/// explicitly when set (logged name-only).
641fn cursor_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
642    if spec.env.contains_key("HOME") {
643        // The verbatim branch carries no .cursor seed (the runner owns the
644        // relocation), but the macOS keychain domain must still exist.
645        #[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
686/// Seed `<scratch_root>/home/.cursor` with [`CURSOR_SEED_ENTRIES`], copied
687/// opaquely (bytes only, no parsing/logging of contents) from the real
688/// home's `.cursor` when present; a missing source yields an
689/// empty-but-present `.cursor`. Returns the home dir the child should get as
690/// `HOME`.
691fn 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
713/// Opaque recursive copy (files only; symlinks and other special entries
714/// are skipped rather than followed).
715fn 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
730/// The HOME one session's child will actually receive, mirroring
731/// [`cursor_child_env`]'s resolution exactly (both branches of
732/// [`crate::agent_env::agent_session_env`] land on one of these two):
733/// a spec-carried relocated HOME is used verbatim; otherwise the
734/// per-session scratch home `<scratch_home_root>/home` — seeded by
735/// [`seed_cursor_scratch_home`], or empty-but-present on seed failure.
736/// The hook-status install resolves the same path so its
737/// `<home>/.cursor/hooks.json` is always in the tree the child sees
738/// (and never the primary checkout).
739fn 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/// Serializes the tests in this module that mutate the process-global env
747/// vars consulted by [`discover_cursor_binary`] (`KRANZ_CURSOR_BIN`, `PATH`,
748/// `HOME`), since `cargo test` runs tests in parallel threads within one
749/// process (mirrors `KIMI_ENV_LOCK`; private because — unlike kimi — cursor's
750/// env-mutating tests live in this one source file).
751#[cfg(test)]
752static CURSOR_ENV_LOCK: Mutex<()> = Mutex::new(());
753
754// ---------------------------------------------------------------------------
755// Binary discovery
756// ---------------------------------------------------------------------------
757
758/// Locate a working cursor `agent` binary.
759///
760/// Order: `KRANZ_CURSOR_BIN` env var → `configured` → `agent` on PATH →
761/// well-known install locations, ending with the Cursor-specific
762/// `~/.local/bin/agent` (per docs/scoping/cursor-cli-backend.md, this is
763/// where the real install lived on the probe host). Each candidate is
764/// validated by running it with `--version`; the first one that succeeds
765/// wins. Errors list every attempt so the user can see what was tried.
766///
767/// `KRANZ_CURSOR_BIN`, when set and non-empty, is an *exclusive* override:
768/// only that path is probed, and a failure is returned immediately rather
769/// than falling through to PATH or the well-known fallback locations. Naming
770/// the binary explicitly and having it not work is an error, not a reason to
771/// search elsewhere.
772pub 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    // Bare names resolve through PATH (std::process handles .cmd/.exe lookup
791    // rules per-platform). The Cursor CLI's binary is `agent`, not `cursor`
792    // (`cursor` is the desktop wrapper).
793    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    // Dedupe, preserving priority order.
802    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/// Well-known install locations checked after PATH, ending with the
824/// Cursor-specific install dir (per docs/scoping/cursor-cli-backend.md).
825#[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/// Well-known install locations checked after PATH (Windows).
841#[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
858/// Deadline for a `--version` probe. Generous for a healthy CLI, but bounds
859/// a hung shim on PATH so binary discovery (`kranz ready`, session spawn)
860/// can never block forever on a candidate.
861const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
862
863/// Validate a candidate by running `<candidate> --version`, draining both
864/// output pipes concurrently while enforcing [`VERSION_PROBE_TIMEOUT`].
865fn probe_version(binary: &Path) -> std::result::Result<String, String> {
866    crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
867}
868
869// ---------------------------------------------------------------------------
870// Argument construction
871// ---------------------------------------------------------------------------
872
873/// The prompt text `agent` actually receives: `append_system_prompt` (if any)
874/// concatenated ahead of the prompt text — the CLI has no
875/// `--append-system-prompt` flag, so the engine folds it into the single
876/// positional prompt argument instead.
877fn 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
888/// Build the argv (excluding the binary itself) for one session.
889///
890/// Public so tests can assert the exact CLI wire format without spawning.
891/// Deliberately ignores every claude-only `SessionSpec` field: `json_schema`,
892/// `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
893/// `disallowed_tools`, `tools`, `settings_json`, `effort` (see module docs).
894/// `--workspace` pins the CLI's workspace to the session cwd (probe item 4,
895/// exercised live); `--worktree` is deliberately never emitted (its behavior
896/// is unobserved by design — kranz does its own worktree isolation), and
897/// neither is `--sandbox` (observed to be no isolation boundary) or
898/// `--stream-partial-output` (complete assistant events are what the parser
899/// consumes; the fixture was captured without it).
900pub 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    // The permission mapping (probe item 6): read-only sessions get the
912    // turn-level read-only `--mode ask`; writable sessions get default mode +
913    // `--force` (the observed unprompted-writes spelling; `--yolo` is only an
914    // inferred alias).
915    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
925// ---------------------------------------------------------------------------
926// `agent --print --output-format stream-json` line parsing
927// ---------------------------------------------------------------------------
928
929/// Parse one stdout line into zero or more [`AgentEvent`]s. `model` is the
930/// configured model id, used as the `Init` fallback (the wire's init event
931/// carries a model DISPLAY string, e.g. "GPT-5.6 Luna 272K Low", which is
932/// preferred when present) and as the pricing key for the terminal event's
933/// client-side cost computation.
934///
935/// Unparseable lines become [`AgentEvent::Other`] with
936/// `raw = {"unparsed": <line>}` so nothing is ever dropped from transcripts
937/// — this is also what the pre-billing plain-text rejections
938/// (`Cannot use this model`, `Authentication required`) arrive as.
939pub 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
948/// Map one parsed `stream-json` value to events (see module docs /
949/// docs/scoping/cursor-cli-backend.md's event-to-`AgentEvent` table).
950/// Unrecognized `type`/`subtype` combinations route to [`AgentEvent::Other`]
951/// rather than being guessed at.
952pub 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        // The `user` event echoes the prompt back; any other `system`
965        // subtype is unobserved. Both are transcript-only.
966        "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
985/// The joined text blocks of an `assistant` event's `message.content`
986/// (the fixture carries exactly one `{"type":"text","text":...}` block;
987/// multiple blocks concatenate so none are dropped).
988fn 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
1002/// The tool kind key under `/tool_call` (`shellToolCall`, `editToolCall`,
1003/// `readToolCall` in the fixture; the discriminated union's member is the
1004/// one key ending in `ToolCall` — the object also carries bookkeeping keys
1005/// like `toolCallId`/`hookAdditionalContexts` that must not be mistaken for
1006/// it). Falls back to `"tool"` when the shape is unobserved.
1007fn 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
1015/// A `tool_call/started` event maps to [`AgentEvent::ToolUse`]; the summary
1016/// is the shell command, the edited/read path, or the call's description,
1017/// whichever the tool's args carry first.
1018fn 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
1040/// A `tool_call/completed` event maps to [`AgentEvent::ToolResult`]. The
1041/// result union discriminates on `success`/`failure`; a `failure` carrying a
1042/// real `exitCode` is a normal failed command, NOT a kranz guardrail denial
1043/// — no in-band permission-denial frame was observed on this wire (kranz's
1044/// no-push invariant for cursor is enforced externally: read-only turn modes
1045/// and scoped credentials), so `denied` is always `false` here rather than
1046/// guessed from text. A completed event with no recognizable result member
1047/// routes to [`AgentEvent::Other`] (unobserved shape).
1048fn 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
1088/// The terminal `result` event: full result text in `.result` (acceptance
1089/// item 1 — no cross-line stitching is needed on this wire; the final
1090/// `assistant` event repeats the same text), usage from the `.usage` object
1091/// (`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`).
1092///
1093/// Absent stays absent, never fabricated: a result with NO `usage` object
1094/// records the zero default and `cost_usd: None` — the wire carries no
1095/// dollar cost (probe item 3), so cost is only ever computed client-side
1096/// from REAL usage tokens via [`cost::usage_cost_usd`].
1097fn 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
1127/// Whether ONE line of CLI output names a known pre-billing rejection
1128/// ([`PRE_BILLING_FAILURE_PHRASES`], probe item 5) — deterministic,
1129/// user-readable, and never retried because no turn was billed.
1130///
1131/// The phrase must LEAD the (trimmed) line (14th-pass review — the match
1132/// was a loose substring over the whole text): the observed rejections are
1133/// the CLI's own plain-text lines (`Cannot use this model: <id>. Available
1134/// models: ...`, `Authentication required`), and anchoring keeps text that
1135/// merely QUOTES a rejection from tripping the detector — a torn
1136/// stream-json fragment riding the transcript as `Other { "unparsed": ... }`
1137/// (torn-line tolerance can land half of an assistant event there) or a
1138/// tool's mid-turn stderr line relaying a remote's "Authentication
1139/// required". A quoted phrase would relabel a failed turn as the
1140/// billing-free config error it wasn't.
1141fn 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
1156/// Keep at most `max` characters (not bytes — never splits a code point).
1157fn 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
1165/// Last `max` characters of `text` (for stderr tails in error messages).
1166fn 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// ---------------------------------------------------------------------------
1173// Backend
1174// ---------------------------------------------------------------------------
1175
1176/// The [`AgentBackend`] for `agent --print --output-format stream-json`:
1177/// single-shot with the `--mode ask` / `--force` permission posture selected
1178/// from the session role.
1179#[derive(Debug, Clone)]
1180pub struct CursorBackend {
1181    binary: PathBuf,
1182}
1183
1184impl CursorBackend {
1185    /// Use an explicit binary path (no validation performed).
1186    pub fn new(binary: impl Into<PathBuf>) -> Self {
1187        CursorBackend {
1188            binary: binary.into(),
1189        }
1190    }
1191
1192    /// Discover the binary via [`discover_cursor_binary`].
1193    pub fn discover(configured: Option<&str>) -> Result<Self> {
1194        Ok(CursorBackend {
1195            binary: discover_cursor_binary(configured)?,
1196        })
1197    }
1198
1199    /// The binary this backend spawns.
1200    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        // agent-env-clear: CLEARED env from the minimal allowlist; the
1217        // scratch HOME is SEEDED with the minimal .cursor account/config
1218        // set (login state does not survive a relocated HOME), and the
1219        // one ambient var a cursor session may authenticate with is
1220        // injected explicitly, never the whole ambient set.
1221        let child_env = cursor_child_env(&spec);
1222
1223        // Ticket agent-hooks-status-signals: install the OPTIONAL hook
1224        // lane into the session-private HOME (the seeded `.cursor` now
1225        // exists, and the tracked project `.cursor/hooks.json` is never
1226        // touched — module docs). The seed/env are unaffected; a failure
1227        // degrades to NO lane with a loud warning, never a spawn error.
1228        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        // Unix: make the child the leader of a fresh process group so aborts
1254        // can kill the whole tree, mirroring `backend_claude::ClaudeBackend`.
1255        #[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        // Windows: kill-on-close Job Object, mirroring `backend_claude`.
1263        #[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        // Capture stderr concurrently so a chatty child never blocks on a
1286        // full pipe and failure messages can include the tail. The stream is
1287        // drained to EOF but only a bounded tail is retained — a noisy or
1288        // malicious CLI must not exhaust host memory (stream_bounds).
1289        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
1318// ---------------------------------------------------------------------------
1319// Session
1320// ---------------------------------------------------------------------------
1321
1322/// A live `agent --print --output-format stream-json` session (the
1323/// [`AgentSession`] impl).
1324///
1325/// Single-shot only: [`send_user_message`](AgentSession::send_user_message)
1326/// always errors, and there is no streaming stdin to hold open.
1327pub struct CursorSession {
1328    session_id: String,
1329    model: String,
1330    /// macOS: the session-private HOME, remembered so the seeded login
1331    /// keychain under it can be relocked when the session ends
1332    /// ([`lock_session_login_keychain`]).
1333    #[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    /// Multi-block lines queue several events; popped one per `next_event`.
1342    queue: VecDeque<AgentEvent>,
1343    saw_result: bool,
1344    saw_success_result: bool,
1345    /// The first unparsed stdout line naming a known pre-billing rejection
1346    /// (`Cannot use this model`, `Authentication required` — probe item 5):
1347    /// recorded so EOF can word the failure as the configuration error it is
1348    /// rather than a retryable transport failure.
1349    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    /// Kill the child and reap it, best-effort; also joins the stderr capture
1384    /// task. Mirrors `backend_claude::ClaudeSession::kill_child` exactly:
1385    /// unix process-group SIGKILL (with a post-reap sweep for stragglers that
1386    /// raced a mid-fork), windows kill-on-close Job Object.
1387    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        // macOS: the session is over — relock the seeded keychain so the
1425        // store is not left open past the session's life (the auto-lock
1426        // timeout is only the backstop).
1427        #[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        // macOS: same relock as kill_child — EOF means the session ended.
1437        #[cfg(target_os = "macos")]
1438        let _ = lock_session_login_keychain(&self.session_home);
1439        // A known pre-billing rejection (probe item 5) is reported as the
1440        // configuration error it is — the caller fixes the model id or
1441        // authenticates; nothing was billed and there is nothing to retry.
1442        // The stdout capture wins; the stderr tail is the fallback for a CLI
1443        // that prints the rejection there instead (matched per line — the
1444        // tail is multi-line and the match is line-anchored). The guard
1445        // matters: a COMPLETED turn (exit 0 with a success result) is never
1446        // re-labeled — a tool's own stderr can legitimately contain one of
1447        // the phrases mid-turn (e.g. a remote's "Authentication required").
1448        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    /// The scratch seed carries the minimal `.cursor` account/config set
1600    /// (login state does not survive a relocated HOME) and never the
1601    /// unbounded transcripts/caches.
1602    #[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    /// A missing real `.cursor` yields an empty-but-present seed (the session
1629    /// then fails auth loudly rather than inheriting).
1630    #[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    /// A spec carrying no relocated HOME (the validator/orchestrator shape)
1643    /// spawns into a freshly seeded scratch HOME: the `.cursor` minimal set
1644    /// crosses, and the child env's HOME points at it.
1645    #[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    /// A `security` invocation against a locked keychain parks on a GUI
1672    /// approval forever (the 2026-08-10 gate hang). The bounded helper must
1673    /// kill the child at the deadline and fail with `TimedOut` naming the
1674    /// bound — never hang. Regression: a stub `security` that sleeps 30s is
1675    /// killed at the 1s test bound.
1676    #[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    /// Run `security` pinned to a session HOME, capturing status+output.
1708    /// Passing the passphrase via argv is fine in tests — the secret is a
1709    /// throwaway and the point under test is its value, not the transport.
1710    /// Callers must pass only subcommands that cannot fall back to
1711    /// interactive auth (see the non-interactivity invariant above
1712    /// [`SESSION_KEYCHAIN_LOCK_SECS`]): `show-keychain-info` only ever
1713    /// against a db the seed JUST reported unlocked.
1714    /// Whether this host's `security` can seed a login keychain under a
1715    /// relocated HOME at all, probed by running the seed itself against a
1716    /// throwaway HOME. On a host where it cannot (the GitHub macOS runner
1717    /// image 20260831.0337.3 refuses it; 20260728.0273.1 did not; a
1718    /// sandboxed developer shell refuses the unlock), the keychain tests
1719    /// skip with the capability marker instead of reporting a runner
1720    /// regression as a defect in the seed. `KRANZ_REQUIRED_CAPABILITIES`
1721    /// can still demand it, in which case the skip is a panic that names
1722    /// the missing capability.
1723    #[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    /// macOS: a relocated HOME gets an EMPTY login keychain (the CLI consults
1748    /// the keychain domain at startup even with CURSOR_API_KEY set and dies
1749    /// with security exit 154 when none resolves through HOME), created with
1750    /// a random per-session passphrase stored 0600 beside the db and left
1751    /// unlocked for the session (auto-lock bounded — never the 300s default
1752    /// that relocked mid-build, never no-timeout).
1753    #[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        // The stored secret is the db's real passphrase by construction —
1781        // one string is both written 0600 and fed to create-keychain — and
1782        // the witnessed first unlock above consumed exactly it.
1783        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    /// macOS: an existing keychain path is never replaced — the seed must
1789    /// not disturb anything already present in the session HOME.
1790    #[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    /// macOS (ticket keychain-passphrase-predictable-permanent-unlock): the
1808    /// seed's passphrase is a random per-session secret — two sessions never
1809    /// share one, it is never derived from the session id, it persists 0600
1810    /// under the session scratch HOME, and a respawn into the same HOME
1811    /// reuses it (the db keeps the passphrase it was created with).
1812    #[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        // Hosted macOS exposed securityd's account-global mutation race when
1823        // this test overlapped the other keychain tests. Start both independent
1824        // homes together and prove the production transaction lock makes both
1825        // seeds reliable.
1826        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        // Both homes were seeded with the SAME session id — the secret must
1851        // not derive from it (the v2 hole was kranz-scratch-{session_id}).
1852        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    /// macOS: the seed restores a bounded auto-lock (never the cleared
1870    /// no-timeout of v2) and leaves the store unlocked for the session.
1871    #[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        // show-keychain-info on a LOCKED db pops a GUI auth dialog (and hung
1891        // the gate suite); it is only called here because the witness above
1892        // proved the db unlocked.
1893        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        // show-keychain-info prints the settings line on STDERR.
1900        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    /// macOS: session teardown relocks the seeded store. The locked state
1912    /// itself is NOT asserted: it cannot be probed non-interactively (the
1913    /// wrong-pass probe lies and re-unlocks via securityd's credential
1914    /// cache for a previously-unlocked login-named db; interrogating a
1915    /// locked db can hang on a GUI dialog — see the ensure doc). What is
1916    /// asserted: the teardown hook ran `lock-keychain` on the session db to
1917    /// exit 0 — verified live 2026-08-09 to genuinely lock (a post-lock
1918    /// `set-keychain-settings` fails 152) — idempotently, and the bounded
1919    /// auto-lock is the independent backstop.
1920    #[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        // The respawn path stays intact: the stored secret re-unlocks
1948        // (prompt-free with -p supplied; also the exact command the next
1949        // spawn's batch A runs).
1950        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 // kSecUnlockStateStatus, Security/SecKeychain.h
1996    }
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    /// macOS: a pre-hardening scratch HOME (db created with the
2018    /// session-derived passphrase, no secret file) still unlocks — the
2019    /// legacy fallback keeps in-flight homes from wedging across the
2020    /// upgrade. The returned witness is honest here: this db path was never
2021    /// successfully unlocked before ensure ran, so no securityd credential
2022    /// cache can mask a wrong passphrase — had the fallback been wrong,
2023    /// batch A would have exited non-zero.
2024    #[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        // Recreate the v2 shape: derived passphrase, no secret file, locked.
2035        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    /// The one sanctioned auth var crosses when set; ambient secrets never do.
2055    #[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        // ENV_TEST_LOCK first: tempfile resolves its parent from ambient
2099        // TMP/TEMP, and env-poisoning tests elsewhere in this binary hold the
2100        // same lock (see `KIMI_ENV_LOCK`'s note in backend_kimi.rs).
2101        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    /// Probe item 2 / the brief's table: a `result.failure` with a real
2278    /// exitCode is a normal failed command, never a guardrail denial.
2279    #[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    /// Absent stays absent: a terminal result with no `usage` object records
2319    /// the zero default and `cost_usd: None` — cost is computed client-side
2320    /// only from REAL usage tokens, never fabricated from nothing.
2321    #[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    /// A torn/unparseable line is never a parse failure: it rides the
2343    /// transcript as `Other { raw: {"unparsed": ... } }`.
2344    #[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    /// Probe item 5: the pre-billing rejection phrases are detected
2357    /// case-insensitively; ordinary output never trips the detector.
2358    #[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    /// 14th-pass review: the match is line-anchored, not a loose substring —
2369    /// text that merely QUOTES a rejection (a torn stream-json fragment of
2370    /// an assistant event, a tool relaying a remote's error mid-line) is not
2371    /// a pre-billing failure.
2372    #[test]
2373    fn pre_billing_match_ignores_quoted_phrases_and_torn_json() {
2374        // A torn stream-json line whose assistant text quotes the phrase.
2375        assert!(!names_pre_billing_failure(
2376            "{\"type\":\"assistant\",\"message\":{\"content\":[{\"text\":\"the remote said Authentication required\""
2377        ));
2378        // Mid-line mentions (a tool's stderr relaying a remote's rejection).
2379        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        // The CLI's own rejection lines still match: phrase-led,
2386        // case-insensitive, leading whitespace tolerated.
2387        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}