Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bt-daemon/src/agents/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Agent for Claude {

impl Register for Claude {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self);
registrar.setup.insert(self.clone());
registrar.run.insert(self);
}
}
3 changes: 2 additions & 1 deletion bt-daemon/src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Agent for Codex {

impl Register for Codex {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self);
registrar.setup.insert(self.clone());
registrar.run.insert(self);
}
}
3 changes: 2 additions & 1 deletion bt-daemon/src/agents/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Agent for Cursor {

impl Register for Cursor {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self);
registrar.setup.insert(self.clone());
registrar.run.insert(self);
}
}
2 changes: 2 additions & 0 deletions bt-daemon/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -77,6 +78,7 @@ impl<T: ?Sized + Agent> Agents<T> {
#[derive(Default)]
pub(crate) struct Registrar {
pub setup: Agents<dyn Setup>,
pub run: Agents<dyn ManagedRun>,
}

pub(crate) trait Register {
Expand Down
3 changes: 2 additions & 1 deletion bt-daemon/src/agents/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Agent for OpenCode {

impl Register for OpenCode {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self);
registrar.setup.insert(self.clone());
registrar.run.insert(self);
}
}
3 changes: 2 additions & 1 deletion bt-daemon/src/agents/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ impl Agent for Pi {

impl Register for Pi {
fn register(self: Arc<Self>, registrar: &mut Registrar) {
registrar.setup.insert(self);
registrar.setup.insert(self.clone());
registrar.run.insert(self);
}
}
12 changes: 12 additions & 0 deletions bt-daemon/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParentObjectType> for SpanObjectType {
fn from(value: ParentObjectType) -> Self {
match value {
Expand Down
23 changes: 20 additions & 3 deletions bt-daemon/src/managed_run/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = &[
Expand All @@ -28,9 +30,7 @@ const CLAUDE_RUN_HOOK_EVENTS: &[&str] = &[
"SessionEnd",
];

pub(super) fn claude_managed_run_args(
hook_command: &RunHookCommand,
) -> anyhow::Result<Vec<OsString>> {
fn claude_managed_run_args(hook_command: &RunHookCommand) -> anyhow::Result<Vec<OsString>> {
let command = hook_command
.program
.to_str()
Expand Down Expand Up @@ -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<Injection> {
Ok(Injection {
args: claude_managed_run_args(hook_command)?,
..Injection::default()
})
}
}
38 changes: 37 additions & 1 deletion bt-daemon/src/managed_run/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<OsString> {
fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec<OsString> {
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");
Expand All @@ -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<Injection> {
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()
})
}
}
109 changes: 106 additions & 3 deletions bt-daemon/src/managed_run/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Injection> {
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<OsString> {
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,
Expand Down Expand Up @@ -34,7 +83,7 @@ pub(super) fn cursor_hook_events() -> anyhow::Result<Vec<String>> {
/// 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<()> {
Expand Down Expand Up @@ -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")];
Expand Down
Loading
Loading