diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 6161663..3fc108a 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -93,7 +93,7 @@ pub async fn run_hook(args: HookArgs, route: SessionRoute, host: HostInfo) -> an let response = (args.source == "cursor").then(|| cursor_hook_response_for_event(args.event.as_deref())); let result = async move { - if suppress_inherited_hook(&args) { + if suppress_hook(&args) { return Ok(()); } let settings = settings::AgentSettings::load_for_hook(&args.source)?; @@ -109,6 +109,40 @@ pub async fn run_hook(args: HookArgs, route: SessionRoute, host: HostInfo) -> an result } +/// Apply capture ownership before either entry point reads stdin or resolves a route. +pub(crate) fn suppress_hook(args: &HookArgs) -> bool { + suppress_inherited_hook(args) || suppress_cursor_plugin_hook(args) +} + +fn suppress_cursor_plugin_hook(args: &HookArgs) -> bool { + cursor_plugin_hook_is_redundant( + args, + &paths::cursor_config_dir(), + std::env::var_os("CURSOR_PLUGIN_ROOT").is_some(), + std::env::var_os(crate::setup::cursor::USER_HOOK_ENV).is_some(), + ) +} + +fn cursor_plugin_hook_is_redundant( + args: &HookArgs, + config_dir: &std::path::Path, + plugin_hook: bool, + user_hook: bool, +) -> bool { + args.source == "cursor" + && !args.managed_run_hook + && plugin_hook + && !user_hook + && match args.event.as_deref() { + Some(event) => { + crate::setup::cursor::user_capture_hook_is_installed_at(config_dir, event) + } + // Older launchers infer the event from stdin; suppress only when + // every user capture registration is present. + None => crate::setup::cursor::capture_hooks_are_installed_at(config_dir), + } +} + fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { if event == Some("beforeSubmitPrompt") { r#"{"continue":true}"# @@ -120,7 +154,7 @@ fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { /// A managed run injects its own hook definitions. Suppress an inherited /// Braintrust plugin hook for the same child, but allow the injected hook /// process, which carries the second marker. -pub(crate) fn suppress_inherited_hook(args: &HookArgs) -> bool { +fn suppress_inherited_hook(args: &HookArgs) -> bool { std::env::var_os("_BT_TRACE_MANAGED_RUN").is_some() && !args.managed_run_hook } @@ -331,6 +365,77 @@ mod tests { args: HookArgs, } + #[test] + fn cursor_capture_ownership_preserves_user_managed_and_uncovered_hooks() { + let temp = tempfile::tempdir().unwrap(); + let specs = crate::setup::cursor::persistent_specs(); + let command = &specs.iter().find(|(event, _)| event == "stop").unwrap().1; + let mut config = json!({"version":1,"hooks":{"stop":[{"command":command,"timeout":10,"failClosed":false}]}}); + let path = temp.path().join("hooks.json"); + std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + let mut args = HookCli::try_parse_from(["test", "--source", "cursor", "--event", "stop"]) + .unwrap() + .args; + assert!(cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + true + )); + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + false, + false + )); + args.managed_run_hook = true; + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + args.managed_run_hook = false; + args.event = Some("sessionStart".into()); + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + args.event = None; + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + for (event, command) in specs { + config["hooks"][event] = json!([{"command":command,"timeout":10,"failClosed":false}]); + } + std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap(); + assert!(cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + args.source = "claude-code".into(); + args.event = Some("stop".into()); + assert!(!cursor_plugin_hook_is_redundant( + &args, + temp.path(), + true, + false + )); + } + #[test] fn hook_plugin_version_reaches_envelope_and_every_initialize_attempt() { let args = HookCli::try_parse_from([ diff --git a/bt-daemon/src/setup/cursor/hooks.rs b/bt-daemon/src/setup/cursor/hooks.rs index 64a6cc9..532effb 100644 --- a/bt-daemon/src/setup/cursor/hooks.rs +++ b/bt-daemon/src/setup/cursor/hooks.rs @@ -1,7 +1,7 @@ -//! Discovery entries in Cursor's user `hooks.json`. +//! Capture and managed-run discovery entries in Cursor's user `hooks.json`. //! -//! Setup registers no-op user-level entries for the lifecycle events tracing -//! depends on, alongside the plugin's own hooks. Managed runs add entries +//! Setup registers fail-open capture entries for every plugin event. Managed +//! runs add no-op discovery entries //! marked with their process identity, which later runs remove once that //! process has exited. @@ -40,8 +40,52 @@ fn specs(marker: &str, windows: bool) -> Vec<(String, String)> { .collect() } -pub(super) fn persistent_specs() -> Vec<(String, String)> { - specs(PERSISTENT_MARKER, cfg!(windows)) +pub(crate) const USER_HOOK_ENV: &str = "BT_TRACE_CURSOR_USER_HOOK"; +const CAPTURE_MARKER: &str = "braintrust-cursor-capture"; + +pub(crate) fn persistent_specs() -> &'static [(String, String)] { + static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SPECS.get_or_init(|| capture_specs(cfg!(windows))) +} + +fn capture_specs(windows: bool) -> Vec<(String, String)> { + let manifest: Value = serde_json::from_str(super::HOOKS_MANIFEST) + .expect("embedded Cursor hooks manifest must be valid"); + manifest["hooks"].as_object().unwrap().iter().map(|(event, entries)| { + let command = entries[0]["command"].as_str().unwrap(); + let response = if event == "beforeSubmitPrompt" { r#"{"continue":true}"# } else { "{}" }; + let command = if windows { + powershell_encoded_command(&format!( + "$env:{USER_HOOK_ENV} = '1'; try {{ & {command} *> $null }} catch {{}}; [Console]::Out.WriteLine('{response}') # {CAPTURE_MARKER}" + )) + } else { + format!("{USER_HOOK_ENV}=1 {command} >/dev/null 2>/dev/null || true; printf '%s\\n' '{response}' # {CAPTURE_MARKER}") + }; + (event.clone(), command) + }).collect() +} + +/// Suppress a plugin registration only when this event has a user capture hook. +pub(crate) fn user_capture_hook_is_installed_at(config_dir: &Path, event: &str) -> bool { + let Ok(config) = load_object(&config_dir.join("hooks.json")) else { + return false; + }; + persistent_specs() + .iter() + .find(|(name, _)| name == event) + .is_some_and(|(event, command)| capture_entry_is_registered(&config, event, command)) +} + +fn capture_entry_is_registered(config: &Map, event: &str, command: &str) -> bool { + config + .get("hooks") + .and_then(|hooks| hooks.get(event)) + .and_then(Value::as_array) + .is_some_and(|entries| { + entries + .iter() + .any(|entry| entry.get("command").and_then(Value::as_str) == Some(command)) + }) } fn powershell_encoded_command(script: &str) -> String { @@ -56,9 +100,8 @@ fn powershell_encoded_command(script: &str) -> String { ) } -/// The process that registered a managed entry, from its marker. -fn managed_process_identity(command: &str) -> Option { - let decoded = command +fn decode_powershell_command(command: &str) -> Option { + command .strip_prefix("powershell.exe -NoProfile -EncodedCommand ") .and_then(|encoded| { use base64::Engine; @@ -74,7 +117,21 @@ fn managed_process_identity(command: &str) -> Option { .map(|pair| u16::from_le_bytes(*pair)) .collect::>(); String::from_utf16(&words).ok() - }); + }) +} + +/// Persistent ownership survives changes to the generated command or event set. +fn is_persistent_command(command: &str) -> bool { + let decoded = decode_powershell_command(command); + let command = decoded.as_deref().unwrap_or(command); + command + .rsplit_once('#') + .is_some_and(|(_, marker)| matches!(marker.trim(), CAPTURE_MARKER | PERSISTENT_MARKER)) +} + +/// The process that registered a managed entry, from its marker. +fn managed_process_identity(command: &str) -> Option { + let decoded = decode_powershell_command(command); let command = decoded.as_deref().unwrap_or(command); let marker = command.split(MANAGED_MARKER_PREFIX).nth(1)?; let mut parts = marker.splitn(3, '-'); @@ -113,7 +170,8 @@ fn hook_entry(command: &str) -> Value { } /// Add (`enable`) or remove the entries for `specs`, preserving every other -/// hook. Adding replaces an existing entry with the same command. +/// hook. Persistent entries are replaced by ownership marker; managed entries +/// are replaced by exact command. fn apply_specs( config: &mut Map, path: &Path, @@ -135,6 +193,18 @@ fn apply_specs( anyhow::anyhow!("Cursor hooks must be a JSON object: {}", path.display()) })?; + let persistent = specs == persistent_specs(); + if persistent { + for entries in hooks.values_mut().filter_map(Value::as_array_mut) { + entries.retain(|entry| { + !entry + .get("command") + .and_then(Value::as_str) + .is_some_and(is_persistent_command) + }); + } + } + for (event, command) in specs { let Some(entries) = hooks.get_mut(event) else { if enable { @@ -182,25 +252,15 @@ pub(super) fn apply( }) } -/// Whether every persistent discovery entry is registered. -pub(crate) fn discovery_hooks_are_installed_at(config_dir: &Path) -> bool { +/// Whether every persistent capture entry is registered. +pub(crate) fn capture_hooks_are_installed_at(config_dir: &Path) -> bool { let path = config_dir.join("hooks.json"); let Ok(config) = load_object(&path) else { return false; }; - let Some(hooks) = config.get("hooks").and_then(Value::as_object) else { - return false; - }; - persistent_specs().iter().all(|(event, command)| { - hooks - .get(event) - .and_then(Value::as_array) - .is_some_and(|entries| { - entries.iter().any(|entry| { - entry.get("command").and_then(Value::as_str) == Some(command.as_str()) - }) - }) - }) + persistent_specs() + .iter() + .all(|(event, command)| capture_entry_is_registered(&config, event, command)) } /// Discovery entries registered for the lifetime of one managed run and @@ -251,18 +311,163 @@ mod tests { use crate::setup::common::write_object_atomic_with; #[test] - fn discovery_health_requires_each_lifecycle_hook() { + fn persistent_setup_reconciles_changed_commands_and_retired_events() { + for windows in [false, true] { + for enable in [false, true] { + let wrap = |script: &str| { + if windows { + powershell_encoded_command(script) + } else { + script.to_owned() + } + }; + let old_capture = wrap("bt trace hook --old-option # braintrust-cursor-capture"); + let old_discovery = wrap("printf '{}' # braintrust-cursor-discovery"); + let managed = wrap("printf '{}' # braintrust-cursor-managed-123-42-run"); + let neighbor = wrap("echo neighbor # braintrust-cursor-capture-custom"); + let mentioned_marker = wrap("echo braintrust-cursor-capture"); + let preserved = serde_json::json!([ + hook_entry(&managed), + hook_entry(&neighbor), + hook_entry(&mentioned_marker), + {"command": 42} + ]); + let mut entries = preserved.as_array().unwrap().clone(); + entries.push(hook_entry(&old_capture)); + entries.push(hook_entry(&old_discovery)); + let config = serde_json::json!({ + "hooks": {"stop": entries.clone(), "retiredEvent": entries} + }) + .as_object() + .unwrap() + .clone(); + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".cursor"); + let path = config_dir.join("hooks.json"); + write_object_atomic_with(&path, config, FileAccess::Inherited).unwrap(); + + // Disable must clean an old install even before it is upgraded. + apply(&config_dir, persistent_specs(), enable).unwrap(); + apply(&config_dir, persistent_specs(), enable).unwrap(); + assert_eq!(capture_hooks_are_installed_at(&config_dir), enable); + for (event, _) in persistent_specs() { + assert_eq!( + user_capture_hook_is_installed_at(&config_dir, event), + enable + ); + } + assert!(!user_capture_hook_is_installed_at(&config_dir, "unknown")); + let config = load_object(&path).unwrap(); + assert_eq!(config["hooks"]["retiredEvent"], preserved); + let mut expected_stop = preserved.as_array().unwrap().clone(); + if enable { + let (_, command) = persistent_specs() + .iter() + .find(|(event, _)| event == "stop") + .unwrap(); + expected_stop.push(hook_entry(command)); + } + assert_eq!(config["hooks"]["stop"], Value::Array(expected_stop)); + + apply(&config_dir, persistent_specs(), false).unwrap(); + assert!(!capture_hooks_are_installed_at(&config_dir)); + let config = load_object(&path).unwrap(); + assert_eq!(config["hooks"]["stop"], preserved); + assert_eq!(config["hooks"]["retiredEvent"], preserved); + } + } + } + + #[cfg(unix)] + #[test] + fn user_capture_hooks_forward_payload_and_fail_open_on_cli_errors() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let bt = temp.path().join("bt"); + // Simulate a CLI parse failure that would otherwise block Cursor. + std::fs::write(&bt, "#!/bin/sh\ncat > \"$CAPTURE_PAYLOAD\"\nprintf '%s\\n' \"$BT_TRACE_CURSOR_USER_HOOK $*\" > \"$CAPTURE_ARGS\"\nprintf 'CLI help'\nexit 2\n").unwrap(); + std::fs::set_permissions(&bt, std::fs::Permissions::from_mode(0o755)).unwrap(); + let payload = br#"{"conversation_id":"session","hook_event_name":"stop"}"#; + for (event, command) in capture_specs(false) { + let mut child = crate::subprocess::interactive_command("/bin/sh") + .args(["-c", &command]) + .env("PATH", format!("{}:/usr/bin:/bin", temp.path().display())) + .env("CAPTURE_PAYLOAD", temp.path().join("payload")) + .env("CAPTURE_ARGS", temp.path().join("args")) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(payload).unwrap(); + let result = child.wait_with_output().unwrap(); + assert!(result.status.success()); + let response = if event == "beforeSubmitPrompt" { + "{\"continue\":true}\n" + } else { + "{}\n" + }; + assert_eq!(result.stdout, response.as_bytes()); + assert!(result.stderr.is_empty()); + assert_eq!(std::fs::read(temp.path().join("payload")).unwrap(), payload); + let args = std::fs::read_to_string(temp.path().join("args")).unwrap(); + assert!(args.starts_with("1 trace hook --source cursor ")); + assert!(args.contains(&format!("--event {event}"))); + } + } + + #[test] + fn capture_commands_cover_all_events_on_both_platforms() { + use base64::Engine; + let manifest: Value = serde_json::from_str(super::super::HOOKS_MANIFEST).unwrap(); + for windows in [false, true] { + let specs = capture_specs(windows); + assert_eq!(specs.len(), manifest["hooks"].as_object().unwrap().len()); + for (event, command) in specs { + let script = if windows { + let bytes = base64::engine::general_purpose::STANDARD + .decode( + command + .strip_prefix("powershell.exe -NoProfile -EncodedCommand ") + .unwrap(), + ) + .unwrap(); + let words = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + String::from_utf16(&words).unwrap() + } else { + command + }; + assert!(script.contains(&format!("--event {event}"))); + assert!(script.contains(USER_HOOK_ENV)); + assert!(script.contains(CAPTURE_MARKER)); + assert!(script.contains(if event == "beforeSubmitPrompt" { + r#"{"continue":true}"# + } else { + "{}" + })); + } + } + } + + #[test] + fn capture_health_requires_each_event() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".cursor"); - apply(&config_dir, &persistent_specs(), true).unwrap(); - assert!(discovery_hooks_are_installed_at(&config_dir)); + apply(&config_dir, persistent_specs(), true).unwrap(); + assert!(capture_hooks_are_installed_at(&config_dir)); let hooks_path = config_dir.join("hooks.json"); let mut config = load_object(&hooks_path).unwrap(); config["hooks"].as_object_mut().unwrap().remove("stop"); write_object_atomic_with(&hooks_path, config, FileAccess::Inherited).unwrap(); - assert!(!discovery_hooks_are_installed_at(&config_dir)); + assert!(!capture_hooks_are_installed_at(&config_dir)); } #[test] @@ -355,7 +560,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".cursor"); - apply(&config_dir, &persistent_specs(), false).unwrap(); + apply(&config_dir, persistent_specs(), false).unwrap(); assert!(!config_dir.exists()); } diff --git a/bt-daemon/src/setup/cursor/mod.rs b/bt-daemon/src/setup/cursor/mod.rs index 709f1f4..6fae555 100644 --- a/bt-daemon/src/setup/cursor/mod.rs +++ b/bt-daemon/src/setup/cursor/mod.rs @@ -1,9 +1,15 @@ //! Cursor: a local plugin written to `~/.cursor/plugins/local/trace-cursor` -//! from files embedded at build time, plus user-level discovery hooks. +//! from files embedded at build time, plus user-level capture hooks. mod hooks; -pub(crate) use hooks::{discovery_hooks_are_installed_at, CursorManagedHooks}; +pub(crate) use hooks::{ + capture_hooks_are_installed_at, user_capture_hook_is_installed_at, CursorManagedHooks, + USER_HOOK_ENV, +}; + +#[cfg(test)] +pub(crate) use hooks::persistent_specs; use crate::agents::Cursor; use crate::paths; @@ -72,10 +78,12 @@ fn ensure_ours(plugin_dir: &Path, unrecognized: &str, different: &str) -> anyhow Ok(()) } -pub(crate) fn plugin_is_installed_at(plugin: &Path) -> bool { +#[cfg(test)] +fn plugin_is_installed_at(plugin: &Path) -> bool { plugin_is_installed_for_platform_at(plugin, cfg!(windows)) } +#[cfg(test)] fn plugin_is_installed_for_platform_at(plugin: &Path, windows: bool) -> bool { if !installed_manifest_at(plugin).is_some_and(|manifest| is_ours(&manifest)) { return false; @@ -157,11 +165,8 @@ impl Setup for Cursor { } fn activation_warning(&self) -> Option<&'static str> { - (!plugin_is_installed_at(&paths::cursor_plugin_dir()) - || !discovery_hooks_are_installed_at(&paths::cursor_config_dir())) - .then_some( - "Cursor tracing plugin or lifecycle hooks are missing; run `bt trace enable cursor`", - ) + (!capture_hooks_are_installed_at(&paths::cursor_config_dir())) + .then_some("Cursor user capture hooks are missing; run `bt trace enable cursor`") } } @@ -322,31 +327,31 @@ fn update_plugin_at(plugin_dir: &Path) -> anyhow::Result<()> { install_plugin_at(plugin_dir) } -/// Change the plugin with `change_plugin`, then reconcile the discovery +/// Change the plugin with `change_plugin`, then reconcile the capture /// hooks. The hooks file is validated first, so an unusable `hooks.json` /// fails before the plugin is touched. -fn with_discovery_hooks( +fn with_capture_hooks( plugin_dir: &Path, config_dir: &Path, install: bool, change_plugin: impl FnOnce(&Path) -> anyhow::Result<()>, ) -> anyhow::Result<()> { let specs = hooks::persistent_specs(); - hooks::validate(config_dir, &specs, install)?; + hooks::validate(config_dir, specs, install)?; change_plugin(plugin_dir)?; - hooks::apply(config_dir, &specs, install) + hooks::apply(config_dir, specs, install) } fn enable_at(plugin_dir: &Path, config_dir: &Path) -> anyhow::Result<()> { - with_discovery_hooks(plugin_dir, config_dir, true, install_plugin_at) + with_capture_hooks(plugin_dir, config_dir, true, install_plugin_at) } fn disable_at(plugin_dir: &Path, config_dir: &Path) -> anyhow::Result<()> { - with_discovery_hooks(plugin_dir, config_dir, false, remove_plugin_at) + with_capture_hooks(plugin_dir, config_dir, false, remove_plugin_at) } fn update_at(plugin_dir: &Path, config_dir: &Path) -> anyhow::Result<()> { - with_discovery_hooks(plugin_dir, config_dir, true, update_plugin_at) + with_capture_hooks(plugin_dir, config_dir, true, update_plugin_at) } #[cfg(test)] @@ -354,16 +359,19 @@ mod tests { use super::*; #[test] - fn install_is_repeatable_and_preserves_neighbor_plugins() { + fn setup_lifecycle_preserves_neighbor_plugins() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("plugins/local"); let plugin = root.join(PLUGIN_NAME); + let config_dir = temp.path().join(".cursor"); let neighbor = root.join("my-plugin/keep.txt"); std::fs::create_dir_all(neighbor.parent().unwrap()).unwrap(); std::fs::write(&neighbor, "leave me").unwrap(); - install_plugin_at(&plugin).unwrap(); - install_plugin_at(&plugin).unwrap(); + enable_at(&plugin, &config_dir).unwrap(); + enable_at(&plugin, &config_dir).unwrap(); + update_at(&plugin, &config_dir).unwrap(); + assert!(capture_hooks_are_installed_at(&config_dir)); assert_eq!(std::fs::read_to_string(&neighbor).unwrap(), "leave me"); assert_eq!( @@ -389,6 +397,11 @@ mod tests { assert!(plugin_is_installed_at(&plugin)); assert!(is_ours(&manifest)); assert!(plugin.join("mcp.json").is_file()); + + disable_at(&plugin, &config_dir).unwrap(); + assert!(!plugin.exists()); + assert!(!capture_hooks_are_installed_at(&config_dir)); + assert_eq!(std::fs::read_to_string(&neighbor).unwrap(), "leave me"); } #[test] @@ -436,18 +449,6 @@ mod tests { assert!(!plugin_is_installed_for_platform_at(&plugin, true)); } - #[test] - fn enable_installs_the_plugin_and_discovery_hooks() { - let temp = tempfile::tempdir().unwrap(); - let plugin = temp.path().join(PLUGIN_NAME); - let config_dir = temp.path().join(".cursor"); - - enable_at(&plugin, &config_dir).unwrap(); - - assert!(plugin_is_installed_at(&plugin)); - assert!(discovery_hooks_are_installed_at(&config_dir)); - } - #[test] fn setup_refuses_to_replace_or_remove_an_unrelated_plugin() { let temp = tempfile::tempdir().unwrap(); @@ -468,20 +469,4 @@ mod tests { assert!(!plugin_is_installed_at(&plugin)); assert!(plugin.join(".cursor-plugin/plugin.json").exists()); } - - #[test] - fn disable_removes_only_its_plugin() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("plugins/local"); - let plugin = root.join(PLUGIN_NAME); - let neighbor = root.join("other-plugin/file"); - std::fs::create_dir_all(neighbor.parent().unwrap()).unwrap(); - std::fs::write(&neighbor, "preserve").unwrap(); - - install_plugin_at(&plugin).unwrap(); - remove_plugin_at(&plugin).unwrap(); - - assert!(!plugin.exists()); - assert_eq!(std::fs::read_to_string(neighbor).unwrap(), "preserve"); - } } diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 310ecfb..b5bbd01 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -653,7 +653,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul run_serve(serve_args, serve_options(&host)).await } TraceCommand::Hook(hook_args) => { - if crate::hook::suppress_inherited_hook(&hook_args) { + if crate::hook::suppress_hook(&hook_args) { return Ok(()); } let settings = crate::settings::AgentSettings::load_for_hook(&hook_args.source)?; @@ -1067,6 +1067,72 @@ mod tests { } } + #[tokio::test] + async fn mounted_cursor_hook_suppresses_only_plugin_capture_before_host_resolution() { + const CHILD_ENV: &str = "BT_TEST_MOUNTED_CURSOR_HOOK"; + if std::env::var_os(CHILD_ENV).is_some() { + let services = Arc::new(RecordingHost::new(Some("route requested"), None)); + let mut args = hook_args(); + args.source = "cursor".into(); + args.event = Some("stop".into()); + let result = run_trace( + TraceArgs { + command: TraceCommand::Hook(args), + }, + test_host(services.clone()), + ) + .await; + if std::env::var_os(crate::setup::cursor::USER_HOOK_ENV).is_some() { + assert_eq!(result.unwrap_err().to_string(), "route requested"); + assert_eq!(services.route_requests.lock().unwrap().len(), 1); + } else { + result.unwrap(); + assert!(services.route_requests.lock().unwrap().is_empty()); + } + return; + } + + // Isolate environment-based ownership from concurrently running tests. + let temp = tempfile::tempdir().unwrap(); + let (_, command) = crate::setup::cursor::persistent_specs() + .iter() + .find(|(event, _)| event == "stop") + .unwrap(); + std::fs::write( + temp.path().join("hooks.json"), + serde_json::json!({"hooks": {"stop": [{"command": command}]}}).to_string(), + ) + .unwrap(); + let settings = temp.path().join("settings.json"); + std::fs::write(&settings, r#"{"trace_to_braintrust":true}"#).unwrap(); + for user_hook in [false, true] { + let mut child = + crate::subprocess::interactive_command(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "trace_runtime::tests::mounted_cursor_hook_suppresses_only_plugin_capture_before_host_resolution", + ]) + .env(CHILD_ENV, "1") + .env(crate::paths::CURSOR_CONFIG_DIR_ENV, temp.path()) + .env(crate::paths::SETTINGS_ENV, &settings) + .env("CURSOR_PLUGIN_ROOT", temp.path()) + .env_remove("_BT_TRACE_MANAGED_RUN") + .env_remove(crate::settings::INVOCATION_SETTINGS_ENV) + .env_remove(crate::setup::cursor::USER_HOOK_ENV); + if user_hook { + child.env(crate::setup::cursor::USER_HOOK_ENV, "1"); + } + let output = child.output().unwrap(); + assert!( + output.status.success(), + "user_hook={user_hook}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + #[tokio::test] async fn hook_host_failures_are_reported() { let services = Arc::new(RecordingHost::new(Some("route unavailable"), None)); diff --git a/src/plugins/cursor/content/README.md b/src/plugins/cursor/content/README.md index fc93ff3..aa7265f 100644 --- a/src/plugins/cursor/content/README.md +++ b/src/plugins/cursor/content/README.md @@ -35,8 +35,11 @@ server named `braintrust`; use one MCP installation to avoid duplicates. ## Supported Cursor surfaces The plugin works with Cursor CLI and desktop. -Its hooks invoke `bt trace hook` directly, so the Braintrust CLI must be on -Cursor's `PATH` when hooks run. +Its hooks invoke `bt trace hook`, so the Braintrust CLI must be on +Cursor's `PATH` when hooks run. `bt trace enable cursor` also installs capture +hooks in `~/.cursor/hooks.json`. These user hooks work with third-party imports +disabled and when a marketplace installation takes precedence over the local +plugin. Plugin hooks skip capture for events covered by these user hooks. ## Quickstart @@ -50,8 +53,8 @@ bt trace enable cursor --project my-coding-agent Setup installs or refreshes the local `braintrust` plugin, configures the hooks needed to trace Cursor sessions, and saves the selected route in `~/.cursor/braintrust.json`. -Cursor must allow local plugin imports. Reload the Cursor window after setup so -the plugin is active. +Reload the Cursor window after setup. Tracing uses the user hooks even when +local plugin imports are unavailable. MCP access still requires an active plugin. Use `--profile` or `--org` to select a different Braintrust account or organization. To use MCP, configure `BRAINTRUST_API_KEY` in Cursor's plugin settings.