From 4aa872749e83418dd8782d48b6a35325f7eb2e35 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 Oct 2026 15:20:13 -0400 Subject: [PATCH 1/3] fix(cursor): install capture hooks in user configuration --- bt-daemon/src/hook.rs | 102 ++++++++++++- bt-daemon/src/setup/cursor/hooks.rs | 212 +++++++++++++++++++++++---- bt-daemon/src/setup/cursor/mod.rs | 39 ++--- src/plugins/cursor/content/README.md | 11 +- 4 files changed, 317 insertions(+), 47 deletions(-) diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 6161663..43ee28f 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_inherited_hook(&args) || suppress_cursor_plugin_hook(&args) { return Ok(()); } let settings = settings::AgentSettings::load_for_hook(&args.source)?; @@ -109,6 +109,35 @@ pub async fn run_hook(args: HookArgs, route: SessionRoute, host: HostInfo) -> an result } +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}"# @@ -331,6 +360,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..1239567 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 { @@ -135,6 +179,17 @@ fn apply_specs( anyhow::anyhow!("Cursor hooks must be a JSON object: {}", path.display()) })?; + let persistent = specs == persistent_specs(); + if persistent { + for (event, command) in self::specs(PERSISTENT_MARKER, cfg!(windows)) { + if let Some(entries) = hooks.get_mut(&event).and_then(Value::as_array_mut) { + entries.retain(|entry| { + entry.get("command").and_then(Value::as_str) != Some(command.as_str()) + }); + } + } + } + for (event, command) in specs { let Some(entries) = hooks.get_mut(event) else { if enable { @@ -182,25 +237,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 +296,135 @@ mod tests { use crate::setup::common::write_object_atomic_with; #[test] - fn discovery_health_requires_each_lifecycle_hook() { + fn capture_setup_migrates_discovery_preserves_neighbors_and_disables_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".cursor"); + let legacy = specs(PERSISTENT_MARKER, cfg!(windows)); + apply(&config_dir, &legacy, true).unwrap(); + let path = config_dir.join("hooks.json"); + let mut config = load_object(&path).unwrap(); + config["hooks"]["stop"] + .as_array_mut() + .unwrap() + .push(hook_entry("other-hook")); + write_object_atomic_with(&path, config, FileAccess::Inherited).unwrap(); + + let capture = persistent_specs(); + apply(&config_dir, capture, true).unwrap(); + apply(&config_dir, capture, true).unwrap(); + assert!(capture_hooks_are_installed_at(&config_dir)); + for (event, _) in capture { + assert!(user_capture_hook_is_installed_at(&config_dir, event)); + } + assert!(!user_capture_hook_is_installed_at(&config_dir, "unknown")); + let installed = load_object(&path).unwrap(); + assert_eq!(installed["hooks"]["stop"].as_array().unwrap().len(), 2); + for (event, command) in legacy { + assert!(installed["hooks"][event] + .as_array() + .unwrap() + .iter() + .all(|entry| entry["command"] != command)); + } + apply(&config_dir, capture, false).unwrap(); + assert!(!capture_hooks_are_installed_at(&config_dir)); + let disabled = load_object(&path).unwrap(); + assert_eq!( + disabled["hooks"]["stop"], + serde_json::json!([hook_entry("other-hook")]) + ); + } + + #[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 +517,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..d084439 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)] @@ -437,7 +442,7 @@ mod tests { } #[test] - fn enable_installs_the_plugin_and_discovery_hooks() { + fn enable_installs_the_plugin_and_capture_hooks() { let temp = tempfile::tempdir().unwrap(); let plugin = temp.path().join(PLUGIN_NAME); let config_dir = temp.path().join(".cursor"); @@ -445,7 +450,7 @@ mod tests { enable_at(&plugin, &config_dir).unwrap(); assert!(plugin_is_installed_at(&plugin)); - assert!(discovery_hooks_are_installed_at(&config_dir)); + assert!(capture_hooks_are_installed_at(&config_dir)); } #[test] 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. From 7beb0bd0c8792d40704d8c1d6cc671815d4587af Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 Oct 2026 15:40:14 -0400 Subject: [PATCH 2/3] fix(cursor): reconcile persistent hooks by ownership marker Remove older capture and discovery commands during enable, update, and disable, including PowerShell entries and retired events. Consolidate setup tests while preserving upgrade and lifecycle coverage. --- bt-daemon/src/setup/cursor/hooks.rs | 135 ++++++++++++++++++---------- bt-daemon/src/setup/cursor/mod.rs | 42 +++------ 2 files changed, 100 insertions(+), 77 deletions(-) diff --git a/bt-daemon/src/setup/cursor/hooks.rs b/bt-daemon/src/setup/cursor/hooks.rs index 1239567..532effb 100644 --- a/bt-daemon/src/setup/cursor/hooks.rs +++ b/bt-daemon/src/setup/cursor/hooks.rs @@ -100,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; @@ -118,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, '-'); @@ -157,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, @@ -181,12 +195,13 @@ fn apply_specs( let persistent = specs == persistent_specs(); if persistent { - for (event, command) in self::specs(PERSISTENT_MARKER, cfg!(windows)) { - if let Some(entries) = hooks.get_mut(&event).and_then(Value::as_array_mut) { - entries.retain(|entry| { - entry.get("command").and_then(Value::as_str) != Some(command.as_str()) - }); - } + 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) + }); } } @@ -296,43 +311,71 @@ mod tests { use crate::setup::common::write_object_atomic_with; #[test] - fn capture_setup_migrates_discovery_preserves_neighbors_and_disables_cleanly() { - let temp = tempfile::tempdir().unwrap(); - let config_dir = temp.path().join(".cursor"); - let legacy = specs(PERSISTENT_MARKER, cfg!(windows)); - apply(&config_dir, &legacy, true).unwrap(); - let path = config_dir.join("hooks.json"); - let mut config = load_object(&path).unwrap(); - config["hooks"]["stop"] - .as_array_mut() - .unwrap() - .push(hook_entry("other-hook")); - write_object_atomic_with(&path, config, FileAccess::Inherited).unwrap(); - - let capture = persistent_specs(); - apply(&config_dir, capture, true).unwrap(); - apply(&config_dir, capture, true).unwrap(); - assert!(capture_hooks_are_installed_at(&config_dir)); - for (event, _) in capture { - assert!(user_capture_hook_is_installed_at(&config_dir, event)); - } - assert!(!user_capture_hook_is_installed_at(&config_dir, "unknown")); - let installed = load_object(&path).unwrap(); - assert_eq!(installed["hooks"]["stop"].as_array().unwrap().len(), 2); - for (event, command) in legacy { - assert!(installed["hooks"][event] - .as_array() + 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() - .iter() - .all(|entry| entry["command"] != command)); + .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); + } } - apply(&config_dir, capture, false).unwrap(); - assert!(!capture_hooks_are_installed_at(&config_dir)); - let disabled = load_object(&path).unwrap(); - assert_eq!( - disabled["hooks"]["stop"], - serde_json::json!([hook_entry("other-hook")]) - ); } #[cfg(unix)] diff --git a/bt-daemon/src/setup/cursor/mod.rs b/bt-daemon/src/setup/cursor/mod.rs index d084439..6fae555 100644 --- a/bt-daemon/src/setup/cursor/mod.rs +++ b/bt-daemon/src/setup/cursor/mod.rs @@ -359,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!( @@ -394,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] @@ -441,18 +449,6 @@ mod tests { assert!(!plugin_is_installed_for_platform_at(&plugin, true)); } - #[test] - fn enable_installs_the_plugin_and_capture_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!(capture_hooks_are_installed_at(&config_dir)); - } - #[test] fn setup_refuses_to_replace_or_remove_an_unrelated_plugin() { let temp = tempfile::tempdir().unwrap(); @@ -473,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"); - } } From 2c80a042fc92398077e101aa49841d52e6886ac8 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 Oct 2026 16:20:18 -0400 Subject: [PATCH 3/3] fix(cursor): suppress plugin capture in mounted hook dispatch Share capture ownership checks across standalone and mounted hook entry points before settings and route resolution. Verify that plugin hooks are suppressed while user hooks still reach the host. --- bt-daemon/src/hook.rs | 9 ++++- bt-daemon/src/trace_runtime.rs | 68 +++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 43ee28f..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) || suppress_cursor_plugin_hook(&args) { + if suppress_hook(&args) { return Ok(()); } let settings = settings::AgentSettings::load_for_hook(&args.source)?; @@ -109,6 +109,11 @@ 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, @@ -149,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 } 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));