From f20a1206da7975baf480d8ae658ab8d682fc42eb Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 6 Oct 2026 20:35:01 +0800 Subject: [PATCH] refactor(run): register managed runs per agent run_traced matched on RunSource throughout. It checked Codex and Cursor arguments, chose executables, created Cursor's plugin directory and discovery hooks, and set OpenCode's inline config. Those branches now live in `ManagedRun` impls on the Codex, Claude Code, Cursor, OpenCode and Pi agent types, which register them alongside setup. run_traced looks up the agent and runs the shared launch, flush and cleanup steps with the arguments, environment and cleanup guards the agent injects. Agent-specific argument checks now run before the destination check, so a Codex run with both problems reports the hook-trust flag first. Co-Authored-By: Claude Opus 5.5 (1M context) --- bt-daemon/src/agents/claude.rs | 3 +- bt-daemon/src/agents/codex.rs | 3 +- bt-daemon/src/agents/cursor.rs | 3 +- bt-daemon/src/agents/mod.rs | 2 + bt-daemon/src/agents/opencode.rs | 3 +- bt-daemon/src/agents/pi.rs | 3 +- bt-daemon/src/args.rs | 12 ++ bt-daemon/src/managed_run/claude.rs | 23 ++- bt-daemon/src/managed_run/codex.rs | 38 +++- bt-daemon/src/managed_run/cursor.rs | 109 ++++++++++- bt-daemon/src/managed_run/mod.rs | 248 ++++++++------------------ bt-daemon/src/managed_run/opencode.rs | 53 +++++- bt-daemon/src/managed_run/pi.rs | 28 +++ 13 files changed, 342 insertions(+), 186 deletions(-) create mode 100644 bt-daemon/src/managed_run/pi.rs diff --git a/bt-daemon/src/agents/claude.rs b/bt-daemon/src/agents/claude.rs index 47034b6..359b22f 100644 --- a/bt-daemon/src/agents/claude.rs +++ b/bt-daemon/src/agents/claude.rs @@ -18,6 +18,7 @@ impl Agent for Claude { impl Register for Claude { fn register(self: Arc, registrar: &mut Registrar) { - registrar.setup.insert(self); + registrar.setup.insert(self.clone()); + registrar.run.insert(self); } } diff --git a/bt-daemon/src/agents/codex.rs b/bt-daemon/src/agents/codex.rs index 8278a28..80741bf 100644 --- a/bt-daemon/src/agents/codex.rs +++ b/bt-daemon/src/agents/codex.rs @@ -18,6 +18,7 @@ impl Agent for Codex { impl Register for Codex { fn register(self: Arc, registrar: &mut Registrar) { - registrar.setup.insert(self); + registrar.setup.insert(self.clone()); + registrar.run.insert(self); } } diff --git a/bt-daemon/src/agents/cursor.rs b/bt-daemon/src/agents/cursor.rs index bf96fc0..d2d411f 100644 --- a/bt-daemon/src/agents/cursor.rs +++ b/bt-daemon/src/agents/cursor.rs @@ -18,6 +18,7 @@ impl Agent for Cursor { impl Register for Cursor { fn register(self: Arc, registrar: &mut Registrar) { - registrar.setup.insert(self); + registrar.setup.insert(self.clone()); + registrar.run.insert(self); } } diff --git a/bt-daemon/src/agents/mod.rs b/bt-daemon/src/agents/mod.rs index 6c2b6f0..e2cfb03 100644 --- a/bt-daemon/src/agents/mod.rs +++ b/bt-daemon/src/agents/mod.rs @@ -21,6 +21,7 @@ pub(crate) use grok::Grok; pub(crate) use opencode::OpenCode; pub(crate) use pi::Pi; +use crate::managed_run::ManagedRun; use crate::setup::Setup; use std::sync::{Arc, OnceLock}; @@ -77,6 +78,7 @@ impl Agents { #[derive(Default)] pub(crate) struct Registrar { pub setup: Agents, + pub run: Agents, } pub(crate) trait Register { diff --git a/bt-daemon/src/agents/opencode.rs b/bt-daemon/src/agents/opencode.rs index 7d61790..2c7b169 100644 --- a/bt-daemon/src/agents/opencode.rs +++ b/bt-daemon/src/agents/opencode.rs @@ -18,6 +18,7 @@ impl Agent for OpenCode { impl Register for OpenCode { fn register(self: Arc, registrar: &mut Registrar) { - registrar.setup.insert(self); + registrar.setup.insert(self.clone()); + registrar.run.insert(self); } } diff --git a/bt-daemon/src/agents/pi.rs b/bt-daemon/src/agents/pi.rs index deede7e..2eb82b4 100644 --- a/bt-daemon/src/agents/pi.rs +++ b/bt-daemon/src/agents/pi.rs @@ -18,6 +18,7 @@ impl Agent for Pi { impl Register for Pi { fn register(self: Arc, registrar: &mut Registrar) { - registrar.setup.insert(self); + registrar.setup.insert(self.clone()); + registrar.run.insert(self); } } diff --git a/bt-daemon/src/args.rs b/bt-daemon/src/args.rs index 90ca7b9..1c8e0a4 100644 --- a/bt-daemon/src/args.rs +++ b/bt-daemon/src/args.rs @@ -206,6 +206,18 @@ impl ImportSource { } } +impl RunSource { + pub(crate) fn identity(self) -> &'static Identity { + match self { + Self::Codex => agents::Codex.identity(), + Self::Cursor => agents::Cursor.identity(), + Self::Claude => agents::Claude.identity(), + Self::OpenCode => agents::OpenCode.identity(), + Self::Pi => agents::Pi.identity(), + } + } +} + impl From for SpanObjectType { fn from(value: ParentObjectType) -> Self { match value { diff --git a/bt-daemon/src/managed_run/claude.rs b/bt-daemon/src/managed_run/claude.rs index f2d1fb8..c00101d 100644 --- a/bt-daemon/src/managed_run/claude.rs +++ b/bt-daemon/src/managed_run/claude.rs @@ -2,6 +2,8 @@ use std::ffi::OsString; +use super::{Injection, ManagedRun}; +use crate::agents::Claude; use crate::args::RunHookCommand; const CLAUDE_RUN_HOOK_EVENTS: &[&str] = &[ @@ -28,9 +30,7 @@ const CLAUDE_RUN_HOOK_EVENTS: &[&str] = &[ "SessionEnd", ]; -pub(super) fn claude_managed_run_args( - hook_command: &RunHookCommand, -) -> anyhow::Result> { +fn claude_managed_run_args(hook_command: &RunHookCommand) -> anyhow::Result> { let command = hook_command .program .to_str() @@ -63,3 +63,20 @@ pub(super) fn claude_managed_run_args( )?), ]) } + +impl ManagedRun for Claude { + fn executable(&self) -> (&'static str, &'static str) { + ("CLAUDE_BIN", "claude") + } + + fn inject( + &self, + hook_command: &RunHookCommand, + _managed_run_id: &str, + ) -> anyhow::Result { + Ok(Injection { + args: claude_managed_run_args(hook_command)?, + ..Injection::default() + }) + } +} diff --git a/bt-daemon/src/managed_run/codex.rs b/bt-daemon/src/managed_run/codex.rs index ed232b0..773ad05 100644 --- a/bt-daemon/src/managed_run/codex.rs +++ b/bt-daemon/src/managed_run/codex.rs @@ -2,6 +2,10 @@ use std::ffi::OsString; +use super::{managed_hook_shell_command, Injection, ManagedRun, Terminal}; +use crate::agents::Codex; +use crate::args::RunHookCommand; + pub(super) const CODEX_RUN_HOOK_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", @@ -16,7 +20,7 @@ pub(super) const CODEX_RUN_HOOK_EVENTS: &[&str] = &[ "SessionEnd", ]; -pub(super) fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec { +fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec { let unix_command = serde_json::to_string(unix_command).expect("serialize hook command"); let windows_command = serde_json::to_string(windows_command).expect("serialize Windows hook command"); @@ -29,3 +33,35 @@ pub(super) fn codex_managed_run_args(unix_command: &str, windows_command: &str) } args } + +impl ManagedRun for Codex { + fn executable(&self) -> (&'static str, &'static str) { + ("CODEX_BIN", "codex") + } + + fn check(&self, agent_args: &[OsString], _terminal: Terminal) -> anyhow::Result<()> { + if agent_args.iter().any(|arg| { + let arg = arg.to_string_lossy(); + arg == "--dangerously-bypass-hook-trust" + || arg.starts_with("--dangerously-bypass-hook-trust=") + }) { + anyhow::bail!( + "bt trace run codex cannot be combined with --dangerously-bypass-hook-trust; managed tracing preserves Codex hook trust. Remove the flag, or run Codex directly" + ); + } + Ok(()) + } + + fn inject( + &self, + hook_command: &RunHookCommand, + _managed_run_id: &str, + ) -> anyhow::Result { + let unix_command = managed_hook_shell_command(hook_command, "codex", false)?; + let windows_command = managed_hook_shell_command(hook_command, "codex", true)?; + Ok(Injection { + args: codex_managed_run_args(&unix_command, &windows_command), + ..Injection::default() + }) + } +} diff --git a/bt-daemon/src/managed_run/cursor.rs b/bt-daemon/src/managed_run/cursor.rs index 7b376b6..64f7be0 100644 --- a/bt-daemon/src/managed_run/cursor.rs +++ b/bt-daemon/src/managed_run/cursor.rs @@ -2,10 +2,59 @@ use std::ffi::OsString; -use super::{managed_hook_shell_command, quote_unix_shell_arg, quote_windows_command_arg}; +use super::{ + managed_hook_shell_command, quote_unix_shell_arg, quote_windows_command_arg, Injection, + ManagedRun, Terminal, +}; +use crate::agents::Cursor; use crate::args::RunHookCommand; +use crate::paths; +use crate::setup::cursor::CursorManagedHooks; -pub(super) fn cursor_run_requires_interactive( +impl ManagedRun for Cursor { + fn executable(&self) -> (&'static str, &'static str) { + ("CURSOR_BIN", "agent") + } + + fn check(&self, agent_args: &[OsString], terminal: Terminal) -> anyhow::Result<()> { + if cursor_run_requires_interactive(agent_args, terminal.stdin, terminal.stdout) { + anyhow::bail!( + "bt trace run cursor requires an interactive terminal for complete lifecycle tracing; Cursor print mode is inferred for non-terminal stdio and does not emit the required prompt, response, and stop hooks" + ); + } + Ok(()) + } + + fn inject( + &self, + hook_command: &RunHookCommand, + managed_run_id: &str, + ) -> anyhow::Result { + let directory = tempfile::Builder::new() + .prefix("bt-trace-cursor-plugin-") + .tempdir()?; + write_cursor_managed_plugin(directory.path(), hook_command)?; + // Cursor's CLI only discovers the plugin lifecycle callbacks when these + // events exist in user/project hook configuration. Keep temporary no-op + // discovery entries for this process and remove only our own entries when + // the managed run exits. + let hooks = CursorManagedHooks::install(&paths::cursor_config_dir(), managed_run_id)?; + Ok(Injection { + args: plugin_dir_args(directory.path()), + env: Vec::new(), + guards: vec![Box::new(hooks), Box::new(directory)], + }) + } +} + +fn plugin_dir_args(directory: &std::path::Path) -> Vec { + vec![ + OsString::from("--plugin-dir"), + directory.as_os_str().to_owned(), + ] +} + +fn cursor_run_requires_interactive( args: &[OsString], stdin_is_terminal: bool, stdout_is_terminal: bool, @@ -34,7 +83,7 @@ pub(super) fn cursor_hook_events() -> anyhow::Result> { /// Build a private plugin that Cursor loads only for the managed invocation. /// The plugin's hooks call this bt front-end directly, so inherited tracing /// plugins are suppressed while these injected hooks remain active. -pub(super) fn write_cursor_managed_plugin( +fn write_cursor_managed_plugin( directory: &std::path::Path, hook_command: &RunHookCommand, ) -> anyhow::Result<()> { @@ -163,6 +212,60 @@ fn cursor_managed_powershell_script(hook_command: &RunHookCommand) -> anyhow::Re mod tests { use super::*; + #[test] + fn cursor_managed_run_injects_an_invocation_local_plugin() { + let temp = tempfile::tempdir().unwrap(); + let plugin = temp.path().join("plugin with spaces – ü"); + std::fs::create_dir(&plugin).unwrap(); + let hook = RunHookCommand { + program: OsString::from("/opt/Braintrust CLI/日本語/bt"), + args: vec![OsString::from("trace"), OsString::from("hook")], + }; + write_cursor_managed_plugin(&plugin, &hook).unwrap(); + let args = plugin_dir_args(&plugin); + assert_eq!( + args, + [OsString::from("--plugin-dir"), plugin.as_os_str().into()] + ); + + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(plugin.join("hooks/hooks.json")).unwrap()) + .unwrap(); + let plugin_manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(plugin.join(".cursor-plugin/plugin.json")).unwrap(), + ) + .unwrap(); + assert_eq!(plugin_manifest["hooks"], "hooks/hooks.json"); + for event in cursor_hook_events().unwrap() { + let entry = &manifest["hooks"][event][0]; + assert_eq!(entry["failClosed"], false); + let launcher = if cfg!(windows) { + "trace.ps1" + } else { + "trace.sh" + }; + assert!(entry["command"].as_str().unwrap().contains(launcher)); + } + if cfg!(windows) { + let script = std::fs::read_to_string(plugin.join("hooks/trace.ps1")).unwrap(); + assert!(script.contains("'--source'")); + assert!(script.contains("'cursor'")); + assert!(script.contains("'--managed-run-hook'")); + assert!(script.contains("'--session-id-field'")); + assert!(script.contains("'conversation_id'")); + assert!(!script.contains("permission\":\"allow")); + assert!(script.contains("{\"continue\":true}")); + assert!(!script.contains("--dangerously-bypass")); + } else { + let script = std::fs::read_to_string(plugin.join("hooks/trace.sh")).unwrap(); + assert!(script.contains("'--source' 'cursor' '--managed-run-hook'")); + assert!(script.contains("'--session-id-field' 'conversation_id'")); + assert!(!script.contains("\"permission\":\"allow\"")); + assert!(script.contains("\"continue\":true")); + assert!(!script.contains("--dangerously-bypass")); + } + } + #[test] fn cursor_managed_run_rejects_explicit_and_inferred_print_modes() { let args = [OsString::from("answer this")]; diff --git a/bt-daemon/src/managed_run/mod.rs b/bt-daemon/src/managed_run/mod.rs index 53f16df..9d2461c 100644 --- a/bt-daemon/src/managed_run/mod.rs +++ b/bt-daemon/src/managed_run/mod.rs @@ -5,24 +5,59 @@ mod claude; mod codex; mod cursor; mod opencode; +mod pi; use std::ffi::OsString; use std::io::IsTerminal; use std::path::PathBuf; -use crate::args::{RunArgs, RunHookCommand, RunSource}; +use crate::agents::{registrar, Agent}; +use crate::args::{RunArgs, RunHookCommand}; use crate::client::{flush_managed_run_in, shutdown_daemon}; use crate::hook::MANAGED_RUN_ID_ENV; use crate::route::resolve_span_plugin_paths; use crate::wire::{self, SessionRoute}; use crate::{paths, settings, subprocess}; -use claude::claude_managed_run_args; -use codex::codex_managed_run_args; -use cursor::{cursor_run_requires_interactive, write_cursor_managed_plugin}; -use opencode::opencode_managed_config; const MANAGED_RUN_FLUSH_TIMEOUT_MS: u64 = 10_000; +/// The agent-specific parts of a managed run. Implemented in each agent's +/// module here and registered in [`crate::agents`]. +pub(crate) trait ManagedRun: Agent { + /// The environment variable that overrides the agent executable, and the + /// executable used otherwise. + fn executable(&self) -> (&'static str, &'static str); + + /// Reject agent arguments or stdio that cannot produce a complete trace. + fn check(&self, _agent_args: &[OsString], _terminal: Terminal) -> anyhow::Result<()> { + Ok(()) + } + + /// Inject hooks for this invocation that call `hook_command`. + fn inject( + &self, + hook_command: &RunHookCommand, + managed_run_id: &str, + ) -> anyhow::Result; +} + +/// Whether the managed run's stdin and stdout are terminals. +#[derive(Clone, Copy)] +pub(crate) struct Terminal { + pub stdin: bool, + pub stdout: bool, +} + +/// What an agent adds to its managed invocation. +#[derive(Default)] +pub(crate) struct Injection { + /// Arguments placed before the user's agent arguments. + pub args: Vec, + pub env: Vec<(&'static str, String)>, + /// Kept alive until the agent exits, then dropped in order to clean up. + pub guards: Vec>, +} + /// Launch a coding agent with inherited stdio and inject Braintrust hooks for /// this invocation, without requiring the tracing plugin to be installed or /// enabled globally. @@ -32,57 +67,28 @@ pub async fn run_traced( mut route: SessionRoute, ) -> anyhow::Result { apply_run_span_plugins(&mut route, &args.plugin)?; - if args.source == RunSource::Cursor - && cursor_run_requires_interactive( - &args.agent_args, - std::io::stdin().is_terminal(), - std::io::stdout().is_terminal(), - ) - { - anyhow::bail!( - "bt trace run cursor requires an interactive terminal for complete lifecycle tracing; Cursor print mode is inferred for non-terminal stdio and does not emit the required prompt, response, and stop hooks" - ); - } + let name = args.source.identity().id; + let agent = registrar() + .run + .get(name) + .unwrap_or_else(|| panic!("no managed run is registered for {name}")); + agent.check( + &args.agent_args, + Terminal { + stdin: std::io::stdin().is_terminal(), + stdout: std::io::stdout().is_terminal(), + }, + )?; if route.destination.is_none() { anyhow::bail!( "managed run requires a trace destination; select a project, object destination, or parent span" ); } - if args.source == RunSource::Codex - && args.agent_args.iter().any(|arg| { - let arg = arg.to_string_lossy(); - arg == "--dangerously-bypass-hook-trust" - || arg.starts_with("--dangerously-bypass-hook-trust=") - }) - { - anyhow::bail!( - "bt trace run codex cannot be combined with --dangerously-bypass-hook-trust; managed tracing preserves Codex hook trust. Remove the flag, or run Codex directly" - ); - } - let (executable_env, default_executable) = match args.source { - RunSource::Codex => ("CODEX_BIN", "codex"), - RunSource::Cursor => ("CURSOR_BIN", "agent"), - RunSource::Claude => ("CLAUDE_BIN", "claude"), - RunSource::OpenCode => ("OPENCODE_BIN", "opencode"), - RunSource::Pi => ("PI_BIN", "pi"), - }; + let (executable_env, default_executable) = agent.executable(); let executable = std::env::var_os(executable_env).unwrap_or_else(|| OsString::from(default_executable)); - let cursor_plugin = if args.source == RunSource::Cursor { - let directory = tempfile::Builder::new() - .prefix("bt-trace-cursor-plugin-") - .tempdir()?; - write_cursor_managed_plugin(directory.path(), &hook_command)?; - Some(directory) - } else { - None - }; - let injected_args = managed_run_args( - args.source, - &hook_command, - cursor_plugin.as_ref().map(tempfile::TempDir::path), - )?; let managed_run_id = uuid::Uuid::new_v4().to_string(); + let injection = agent.inject(&hook_command, &managed_run_id)?; // A caller-supplied socket is already an explicit daemon boundary (for // example an integration harness or a deliberately isolated runtime). // Only create our own boundary when environment auth would otherwise use @@ -95,7 +101,7 @@ pub async fn run_traced( let invocation_settings = serde_json::to_string(&settings::InvocationSettings::enabled(route))?; let mut command: tokio::process::Command = subprocess::interactive_command(&executable).into(); command - .args(injected_args) + .args(injection.args) .args(args.agent_args) .env("_BT_TRACE_MANAGED_RUN", "1") .env(MANAGED_RUN_ID_ENV, &managed_run_id) @@ -105,24 +111,8 @@ pub async fn run_traced( .env(paths::SOCKET_ENV, &runtime.socket) .env(paths::DATA_DIR_ENV, runtime.temp_dir.path()); } - if args.source == RunSource::OpenCode { - command.env( - "OPENCODE_CONFIG_CONTENT", - opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref())?, - ); - } - // Cursor's CLI only discovers the plugin lifecycle callbacks when these - // events exist in user/project hook configuration. Keep temporary no-op - // discovery entries for this process and remove only our own entries when - // the managed run exits. - let _cursor_hooks = if args.source == RunSource::Cursor { - Some(crate::setup::cursor::CursorManagedHooks::install( - &paths::cursor_config_dir(), - &managed_run_id, - )?) - } else { - None - }; + command.envs(injection.env); + let _guards = injection.guards; let mut child = command.spawn().map_err(|error| { anyhow::anyhow!("failed to launch {}: {error}", executable.to_string_lossy()) })?; @@ -206,38 +196,6 @@ fn apply_run_span_plugins(route: &mut SessionRoute, plugins: &[PathBuf]) -> anyh Ok(()) } -fn managed_run_args( - source: RunSource, - hook_command: &RunHookCommand, - cursor_plugin_dir: Option<&std::path::Path>, -) -> anyhow::Result> { - match source { - RunSource::Codex => { - let unix_command = managed_hook_shell_command(hook_command, "codex", false)?; - let windows_command = managed_hook_shell_command(hook_command, "codex", true)?; - Ok(codex_managed_run_args(&unix_command, &windows_command)) - } - RunSource::Claude => claude_managed_run_args(hook_command), - RunSource::Cursor => { - let directory = cursor_plugin_dir.ok_or_else(|| { - anyhow::anyhow!("managed Cursor run requires an invocation-local plugin directory") - })?; - Ok(vec![ - OsString::from("--plugin-dir"), - directory.as_os_str().to_owned(), - ]) - } - RunSource::OpenCode => Ok(Vec::new()), - RunSource::Pi => { - let extension = match std::env::var_os("BT_TRACE_PI_PLUGIN_SPEC") { - Some(extension) => extension, - None => OsString::from(crate::setup::pi::plugin_spec()), - }; - Ok(vec![OsString::from("-e"), extension]) - } - } -} - pub(super) fn managed_hook_shell_command( hook_command: &RunHookCommand, source: &str, @@ -274,8 +232,23 @@ pub(super) fn quote_windows_command_arg(arg: &str) -> String { #[cfg(test)] mod tests { use super::codex::CODEX_RUN_HOOK_EVENTS; - use super::cursor::cursor_hook_events; use super::*; + use crate::agents::{Codex, Pi}; + use crate::args::RunSource; + + #[test] + fn every_run_source_has_a_registered_managed_run() { + for source in [ + RunSource::Codex, + RunSource::Cursor, + RunSource::Claude, + RunSource::OpenCode, + RunSource::Pi, + ] { + let agent = registrar().run.get(source.identity().id).unwrap(); + assert_eq!(agent.identity().id, source.identity().id); + } + } #[test] fn managed_run_plugins_replace_inherited_plugins() { @@ -349,7 +322,7 @@ mod tests { #[test] fn codex_managed_run_injects_live_hooks() { - let args = managed_run_args(RunSource::Codex, &test_run_hook_command(), None).unwrap(); + let args = Codex.inject(&test_run_hook_command(), "run").unwrap().args; assert_eq!(args[0], "--enable"); assert_eq!(args[1], "hooks"); assert!(!args @@ -374,81 +347,10 @@ mod tests { assert!(!config.contains("transcript")); } - #[test] - fn cursor_managed_run_injects_an_invocation_local_plugin() { - let temp = tempfile::tempdir().unwrap(); - let plugin = temp.path().join("plugin with spaces – ü"); - std::fs::create_dir(&plugin).unwrap(); - let hook = RunHookCommand { - program: OsString::from("/opt/Braintrust CLI/日本語/bt"), - args: vec![OsString::from("trace"), OsString::from("hook")], - }; - write_cursor_managed_plugin(&plugin, &hook).unwrap(); - let args = managed_run_args(RunSource::Cursor, &hook, Some(&plugin)).unwrap(); - assert_eq!( - args, - [OsString::from("--plugin-dir"), plugin.as_os_str().into()] - ); - - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(plugin.join("hooks/hooks.json")).unwrap()) - .unwrap(); - let plugin_manifest: serde_json::Value = serde_json::from_slice( - &std::fs::read(plugin.join(".cursor-plugin/plugin.json")).unwrap(), - ) - .unwrap(); - assert_eq!(plugin_manifest["hooks"], "hooks/hooks.json"); - for event in cursor_hook_events().unwrap() { - let entry = &manifest["hooks"][event][0]; - assert_eq!(entry["failClosed"], false); - let launcher = if cfg!(windows) { - "trace.ps1" - } else { - "trace.sh" - }; - assert!(entry["command"].as_str().unwrap().contains(launcher)); - } - if cfg!(windows) { - let script = std::fs::read_to_string(plugin.join("hooks/trace.ps1")).unwrap(); - assert!(script.contains("'--source'")); - assert!(script.contains("'cursor'")); - assert!(script.contains("'--managed-run-hook'")); - assert!(script.contains("'--session-id-field'")); - assert!(script.contains("'conversation_id'")); - assert!(!script.contains("permission\":\"allow")); - assert!(script.contains("{\"continue\":true}")); - assert!(!script.contains("--dangerously-bypass")); - } else { - let script = std::fs::read_to_string(plugin.join("hooks/trace.sh")).unwrap(); - assert!(script.contains("'--source' 'cursor' '--managed-run-hook'")); - assert!(script.contains("'--session-id-field' 'conversation_id'")); - assert!(!script.contains("\"permission\":\"allow\"")); - assert!(script.contains("\"continue\":true")); - assert!(!script.contains("--dangerously-bypass")); - } - } - - #[test] - fn opencode_managed_run_preserves_inline_config_and_adds_plugin() { - let config = - opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#)).unwrap(); - let config: serde_json::Value = serde_json::from_str(&config).unwrap(); - assert_eq!(config["model"], "test/model"); - assert_eq!( - config["plugin"], - serde_json::json!(["other", "@braintrust/trace-opencode/tracing"]) - ); - assert!( - managed_run_args(RunSource::OpenCode, &test_run_hook_command(), None) - .unwrap() - .is_empty() - ); - } - #[test] fn pi_managed_run_loads_the_npm_extension() { assert_eq!( - managed_run_args(RunSource::Pi, &test_run_hook_command(), None).unwrap(), + Pi.inject(&test_run_hook_command(), "run").unwrap().args, vec![ OsString::from("-e"), OsString::from(crate::setup::pi::plugin_spec()), diff --git a/bt-daemon/src/managed_run/opencode.rs b/bt-daemon/src/managed_run/opencode.rs index 86ed4be..f5bb309 100644 --- a/bt-daemon/src/managed_run/opencode.rs +++ b/bt-daemon/src/managed_run/opencode.rs @@ -1,6 +1,10 @@ //! OpenCode managed runs add the tracing plugin to inline config. -pub(super) fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result { +use super::{Injection, ManagedRun}; +use crate::agents::OpenCode; +use crate::args::RunHookCommand; + +fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result { let mut config = match existing { Some(raw) => serde_json::from_str::(raw) .map_err(|error| anyhow::anyhow!("invalid OPENCODE_CONFIG_CONTENT: {error}"))?, @@ -21,3 +25,50 @@ pub(super) fn opencode_managed_config(existing: Option<&str>) -> anyhow::Result< } Ok(serde_json::to_string(&config)?) } + +impl ManagedRun for OpenCode { + fn executable(&self) -> (&'static str, &'static str) { + ("OPENCODE_BIN", "opencode") + } + + fn inject( + &self, + _hook_command: &RunHookCommand, + _managed_run_id: &str, + ) -> anyhow::Result { + let config = + opencode_managed_config(std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref())?; + Ok(Injection { + env: vec![("OPENCODE_CONFIG_CONTENT", config)], + ..Injection::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opencode_managed_run_preserves_inline_config_and_adds_plugin() { + let config = + opencode_managed_config(Some(r#"{"model":"test/model","plugin":["other"]}"#)).unwrap(); + let config: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(config["model"], "test/model"); + assert_eq!( + config["plugin"], + serde_json::json!(["other", "@braintrust/trace-opencode/tracing"]) + ); + assert!(OpenCode + .inject( + &RunHookCommand { + program: "bt".into(), + args: Vec::new(), + }, + "run", + ) + .unwrap() + .args + .is_empty()); + } +} diff --git a/bt-daemon/src/managed_run/pi.rs b/bt-daemon/src/managed_run/pi.rs new file mode 100644 index 0000000..50f01f1 --- /dev/null +++ b/bt-daemon/src/managed_run/pi.rs @@ -0,0 +1,28 @@ +//! Pi managed runs load the tracing extension with `-e`. + +use std::ffi::OsString; + +use super::{Injection, ManagedRun}; +use crate::agents::Pi; +use crate::args::RunHookCommand; + +impl ManagedRun for Pi { + fn executable(&self) -> (&'static str, &'static str) { + ("PI_BIN", "pi") + } + + fn inject( + &self, + _hook_command: &RunHookCommand, + _managed_run_id: &str, + ) -> anyhow::Result { + let extension = match std::env::var_os("BT_TRACE_PI_PLUGIN_SPEC") { + Some(extension) => extension, + None => OsString::from(crate::setup::pi::plugin_spec()), + }; + Ok(Injection { + args: vec![OsString::from("-e"), extension], + ..Injection::default() + }) + } +}