From 1a54daad216f0e5dc741ddcd3d390c9ea10de531 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 21 Oct 2026 02:13:57 +0800 Subject: [PATCH 1/2] Delegate hook capture through BRAINTRUST_BT_BIN --- bt-daemon/src/hook_executable.rs | 147 ++++++++++++++++++ bt-daemon/src/lib.rs | 2 + bt-daemon/src/main.rs | 4 + bt-daemon/src/trace_runtime.rs | 1 + .../content/plugins/trace-codex/README.md | 19 +++ 5 files changed, 173 insertions(+) create mode 100644 bt-daemon/src/hook_executable.rs diff --git a/bt-daemon/src/hook_executable.rs b/bt-daemon/src/hook_executable.rs new file mode 100644 index 00000000..3af8aa14 --- /dev/null +++ b/bt-daemon/src/hook_executable.rs @@ -0,0 +1,147 @@ +//! Select the hook executable before consuming input or resolving configuration. + +use anyhow::Context; +use std::path::Path; + +const TARGET_ENV: &str = "BRAINTRUST_BT_BIN"; +const DELEGATED_ENV: &str = "BRAINTRUST_HOOK_DELEGATED"; + +/// Delegate a CLI hook invocation to an explicitly selected executable. +/// +/// This is process-level dispatch: on success it replaces or exits the calling +/// process. Embedders should call it only at the CLI hook entry point. +pub fn delegate_hook_executable() -> anyhow::Result<()> { + let Some(target) = crate::env::var_os(TARGET_ENV) else { + return Ok(()); + }; + let target = Path::new(&target); + anyhow::ensure!( + target.is_absolute(), + "{TARGET_ENV} must be an absolute executable path, not a PATH lookup or version-manager shim" + ); + let target = target.canonicalize().with_context(|| { + format!( + "cannot resolve {TARGET_ENV} executable {}", + target.display() + ) + })?; + let current = std::env::current_exe()?.canonicalize()?; + if target == current { + return Ok(()); + } + anyhow::ensure!( + crate::env::var_os(DELEGATED_ENV).is_none(), + "{TARGET_ENV} delegation loop: point it at the actual bt executable, not a wrapper or version-manager shim" + ); + let mut command = crate::subprocess::background_command(&target); + command + .args(std::env::args_os().skip(1)) + .env(DELEGATED_ENV, "1"); + // Standard streams are inherited untouched, including the unread payload. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = command.exec(); + Err(error) + .with_context(|| format!("cannot execute {TARGET_ENV} target {}", target.display())) + } + #[cfg(not(unix))] + { + let status = command + .status() + .with_context(|| format!("cannot execute {TARGET_ENV} target {}", target.display()))?; + std::process::exit(status.code().unwrap_or(1)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::process::Stdio; + + const CHILD_ENV: &str = "BRAINTRUST_TEST_DELEGATE_CHILD"; + + #[test] + fn child_entry() { + if crate::env::var_os(CHILD_ENV).is_none() { + return; + } + if let Err(error) = delegate_hook_executable() { + eprintln!("{error:#}"); + std::process::exit(1); + } + let mut payload = String::new(); + std::io::stdin().read_to_string(&mut payload).unwrap(); + println!("payload={payload}"); + println!("args={:?}", std::env::args_os().skip(1).collect::>()); + eprintln!("target stderr"); + std::process::exit(23); + } + + fn child(target: &Path, loop_guard: bool) -> std::process::Output { + let mut command = crate::subprocess::background_command(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "hook_executable::tests::child_entry", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .env(TARGET_ENV, target) + .env_remove(DELEGATED_ENV) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if loop_guard { + command.env(DELEGATED_ENV, "1"); + } + let mut child = command.spawn().unwrap(); + let _ = child + .stdin + .take() + .unwrap() + .write_all(b"{\"raw\": [1, 2]}\n"); + child.wait_with_output().unwrap() + } + + #[test] + fn delegation_preserves_input_arguments_streams_and_status() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp + .path() + .join(format!("selected bt{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(std::env::current_exe().unwrap(), &target).unwrap(); + let output = child(&target, false); + assert_eq!(output.status.code(), Some(23), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("payload={\"raw\": [1, 2]}\n"), "{stdout}"); + assert!(stdout.contains("--exact"), "{stdout}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("target stderr")); + let output = child(&target, true); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("delegation loop")); + } + + #[test] + fn current_executable_continues_and_invalid_targets_fail_clearly() { + assert_eq!( + child(&std::env::current_exe().unwrap(), true).status.code(), + Some(23) + ); + for (target, message) in [ + (std::path::PathBuf::from("bt"), "absolute executable path"), + ( + tempfile::tempdir().unwrap().path().join("missing-bt"), + "cannot resolve", + ), + ] { + let output = child(&target, false); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains(message), + "{output:?}" + ); + } + } +} diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 61a3f58f..8ebb84bf 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -21,6 +21,7 @@ mod delivery_ledger; mod dispatch; mod env; mod hook; +mod hook_executable; mod ids; mod journal; mod managed_run; @@ -56,6 +57,7 @@ pub use command_output::{ OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput, }; pub use hook::run_hook; +pub use hook_executable::delegate_hook_executable; #[doc(hidden)] pub use journal::source_journal_path; pub use managed_run::run_traced; diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 2af7390b..1f38daf2 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -263,6 +263,10 @@ async fn main() { } } Command::Hook { args, route } => { + if let Err(error) = bt_daemon::delegate_hook_executable() { + eprintln!("bt-daemon hook: {error:#}"); + std::process::exit(1); + } if let Err(e) = run_hook(args, route.into_route(), host_info()).await { eprintln!("bt-daemon hook: {e}"); std::process::exit(1); diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 684c97b6..bc993238 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -666,6 +666,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul run_serve(serve_args, serve_options(&host)).await } TraceCommand::Hook(hook_args) => { + crate::delegate_hook_executable()?; // Cursor permission hooks must emit a response on this production // entry point too. Keep it outside capture and route resolution so // tracing failures cannot leave Cursor waiting on an empty reply. diff --git a/src/plugins/codex/content/plugins/trace-codex/README.md b/src/plugins/codex/content/plugins/trace-codex/README.md index efc5553e..18423e5b 100644 --- a/src/plugins/codex/content/plugins/trace-codex/README.md +++ b/src/plugins/codex/content/plugins/trace-codex/README.md @@ -13,6 +13,25 @@ journaling, trace construction, and delivery to Braintrust. It discovers the installed plugin and Codex versions dynamically. No credentials are stored in the plugin. +### Selecting the CLI executable + +Hooks resolve `bt` from the Codex process's PATH. With version managers such as +mise, that can select a different version depending on the working directory. +Set `BRAINTRUST_BT_BIN` to the absolute path of the actual CLI executable to +delegate hook capture to a fixed version: + +```bash +export BRAINTRUST_BT_BIN="/absolute/path/to/bt" +codex +``` + +Codex must inherit this variable; GUI launches may need separate environment +configuration. Point it at an executable rather than a version-manager shim. +The PATH-resolved `bt` must also be upgraded to a release supporting delegation: +older releases cannot honor this setting (versions before 0.16 lack `trace` +entirely). A missing or invalid target reports an error instead of silently +falling back. The marketplace hook definition stays unchanged. + ## Setup Install and configure the published plugin with the Braintrust CLI: From 88fc32e56393c240e0e8465631eab62f1ebb8b44 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 21 Oct 2026 02:28:58 +0800 Subject: [PATCH 2/2] Keep hook delegation scoped to the mounted bt command --- bt-daemon/src/main.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 1f38daf2..2af7390b 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -263,10 +263,6 @@ async fn main() { } } Command::Hook { args, route } => { - if let Err(error) = bt_daemon::delegate_hook_executable() { - eprintln!("bt-daemon hook: {error:#}"); - std::process::exit(1); - } if let Err(e) = run_hook(args, route.into_route(), host_info()).await { eprintln!("bt-daemon hook: {e}"); std::process::exit(1);