Skip to content
419 changes: 419 additions & 0 deletions bt-daemon/src/codex_hooks.rs

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ impl TraceCommandOutput {
display_name: impl Into<String>,
settings_path: impl Into<PathBuf>,
) -> Self {
let source = source.into();
Self::Enable(SetupCommandOutput {
source: source.into(),
source,
display_name: display_name.into(),
settings_path: settings_path.into(),
restart_required: true,
Expand Down Expand Up @@ -216,8 +217,9 @@ impl TraceCommandOutput {
}

pub fn update(source: impl Into<String>, display_name: impl Into<String>) -> Self {
let source = source.into();
Self::Update(UpdateCommandOutput {
source: source.into(),
source,
display_name: display_name.into(),
restart_required: true,
})
Expand Down Expand Up @@ -427,6 +429,26 @@ mod tests {
.contains("removed for Google Antigravity"));
}

#[test]
fn codex_setup_and_updates_do_not_request_manual_trust() {
for output in [
TraceCommandOutput::setup("codex", "Codex", PathBuf::from("braintrust.json")),
TraceCommandOutput::update("codex", "Codex"),
] {
let human = output.render(OutputFormat::Human).unwrap();
assert!(!human.contains("Open /hooks"));
let json: serde_json::Value =
serde_json::from_str(&output.render(OutputFormat::Json).unwrap()).unwrap();
assert!(json.get("next_steps").is_none());
}
let disabled =
TraceCommandOutput::disable("codex", "Codex", PathBuf::from("braintrust.json"));
assert!(!disabled
.render(OutputFormat::Human)
.unwrap()
.contains("Open /hooks"));
}

#[test]
fn stop_json_reports_idempotent_and_successful_shutdowns() {
let absent: serde_json::Value = serde_json::from_str(
Expand Down
1 change: 1 addition & 0 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod paths;
mod agents;
mod args;
mod client;
mod codex_hooks;
mod command_output;
mod correlation;
mod delivery_ledger;
Expand Down
81 changes: 62 additions & 19 deletions bt-daemon/src/managed_run/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,80 @@ 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",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SubagentStart",
"SubagentStop",
"Stop",
"SessionEnd",
];
pub(super) const PACKAGED_HOOKS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json"
));

pub(super) fn managed_commands(hook_command: &RunHookCommand) -> anyhow::Result<(String, String)> {
Ok((
managed_hook_shell_command(hook_command, "codex", false)?,
managed_hook_shell_command(hook_command, "codex", true)?,
))
}

fn codex_managed_run_args(
unix_command: &str,
windows_command: &str,
) -> anyhow::Result<Vec<OsString>> {
let unix_command = serde_json::to_string(unix_command)?;
let windows_command = serde_json::to_string(windows_command)?;
let manifest: serde_json::Value = serde_json::from_str(PACKAGED_HOOKS)?;
let mut args = vec![OsString::from("--enable"), OsString::from("hooks")];
for event in CODEX_RUN_HOOK_EVENTS {
for (event, groups) in manifest["hooks"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("Codex hook manifest has no hooks map"))?
{
let mut groups = groups.clone();
for group in groups
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("Codex hook groups must be an array"))?
{
for hook in group["hooks"]
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("Codex hook group has no hooks array"))?
{
anyhow::ensure!(hook["type"] == "command", "Unsupported Codex hook type");
hook["command"] = serde_json::Value::String(unix_command.to_owned());
hook["commandWindows"] = serde_json::Value::String(windows_command.to_owned());
}
}
args.push(OsString::from("-c"));
args.push(OsString::from(format!(
"hooks.{event}=[{{hooks=[{{type=\"command\",command={unix_command},commandWindows={windows_command}}}]}}]"
"hooks.{event}={}",
toml_literal(&groups)?
)));
}
Ok(args)
}

fn toml_literal(value: &serde_json::Value) -> anyhow::Result<String> {
Ok(match value {
serde_json::Value::Null => anyhow::bail!("null is unsupported in Codex hook config"),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::String(value) => serde_json::to_string(value)?,
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(toml_literal)
.collect::<anyhow::Result<Vec<_>>>()?
.join(",")
),
serde_json::Value::Object(values) => format!(
"{{{}}}",
values
.iter()
.map(|(key, value)| Ok(format!(
"{}={}",
serde_json::to_string(key)?,
toml_literal(value)?
)))
.collect::<anyhow::Result<Vec<_>>>()?
.join(",")
),
})
}

impl ManagedRun for Codex {
fn executable(&self) -> (&'static str, &'static str) {
("CODEX_BIN", "codex")
Expand All @@ -59,8 +103,7 @@ impl ManagedRun for Codex {
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)?;
let (unix_command, windows_command) = managed_commands(hook_command)?;
Ok(Injection {
args: codex_managed_run_args(&unix_command, &windows_command)?,
..Injection::default()
Expand Down
45 changes: 38 additions & 7 deletions bt-daemon/src/managed_run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::io::IsTerminal;
use std::path::PathBuf;

use crate::agents::{registrar, Agent};
use crate::args::{RunArgs, RunHookCommand};
use crate::args::{RunArgs, RunHookCommand, RunSource};
use crate::client::{flush_managed_run_in, shutdown_daemon};
use crate::hook::MANAGED_RUN_ID_ENV;
use crate::route::resolve_span_plugin_paths;
Expand Down Expand Up @@ -88,7 +88,28 @@ pub async fn run_traced(
let executable =
std::env::var_os(executable_env).unwrap_or_else(|| OsString::from(default_executable));
let managed_run_id = uuid::Uuid::new_v4().to_string();
let injection = agent.inject(&hook_command, &managed_run_id)?;
let mut injection = agent.inject(&hook_command, &managed_run_id)?;
if args.source == RunSource::Codex {
let executable_for_trust =
std::env::var_os("CODEX_BIN").unwrap_or_else(|| OsString::from("codex"));
let cwd = std::env::current_dir()?;
let (unix_command, windows_command) =
crate::managed_run::codex::managed_commands(&hook_command)?;
let expected_command = if cfg!(windows) {
windows_command
} else {
unix_command
};
let trust_override = crate::codex_hooks::run_trust_override(
&executable_for_trust,
&cwd,
&injection.args,
&expected_command,
)
.await?;
injection.args.push(OsString::from("-c"));
injection.args.push(trust_override);
}
// 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
Expand Down Expand Up @@ -231,7 +252,6 @@ pub(super) fn quote_windows_command_arg(arg: &str) -> String {

#[cfg(test)]
mod tests {
use super::codex::CODEX_RUN_HOOK_EVENTS;
use super::*;
use crate::agents::{Codex, Pi};
use crate::args::RunSource;
Expand Down Expand Up @@ -328,10 +348,21 @@ mod tests {
assert!(!args
.iter()
.any(|arg| arg == "--dangerously-bypass-hook-trust"));
assert_eq!(
args.iter().filter(|arg| *arg == "-c").count(),
CODEX_RUN_HOOK_EVENTS.len()
);
let hook_overrides: Vec<_> = args
.iter()
.filter_map(|arg| arg.to_str())
.filter(|arg| arg.starts_with("hooks."))
.collect();
let manifest: serde_json::Value =
serde_json::from_str(super::codex::PACKAGED_HOOKS).unwrap();
let expected_events = manifest["hooks"].as_object().unwrap();
assert_eq!(hook_overrides.len(), expected_events.len());
assert!(hook_overrides
.iter()
.any(|arg| arg.starts_with("hooks.Interrupt=")));
assert!(hook_overrides
.iter()
.any(|arg| arg.starts_with("hooks.SessionEnd=")));
let config = args
.iter()
.find_map(|arg| {
Expand Down
23 changes: 14 additions & 9 deletions bt-daemon/src/setup/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use super::common::{
};
use crate::agents::Codex;
use crate::setup::Setup;
use anyhow::bail;
use serde_json::Value;

const MARKETPLACE_NAME: &str = "braintrust-codex-plugins";
Expand Down Expand Up @@ -63,7 +62,9 @@ impl Setup for Codex {

fn update(&self, runner: &mut dyn CommandRunner) -> anyhow::Result<()> {
if installed_plugin(&list_plugins(runner)?).is_none() {
bail!("Codex tracing plugin is not installed; run `bt trace enable codex`");
// Setup owns the desired state: repair a removed plugin the same
// way enable does, then let the caller reapply and verify trust.
return self.enable(runner);
}
MARKETPLACE.refresh_published(runner, "Codex", "codex")?;
runner.run("codex", &["plugin", "add", PLUGIN])
Expand Down Expand Up @@ -138,14 +139,18 @@ mod tests {
}

#[test]
fn update_refuses_to_install_a_missing_plugin() {
let mut runner = FakeRunner::new([serde_json::json!({"installed": []})]);
fn update_repairs_a_missing_plugin() {
let mut runner = FakeRunner::new([
serde_json::json!({"installed": []}),
serde_json::json!({"marketplaces": []}),
]);

assert!(Codex.update(&mut runner).is_err());
assert!(!runner
.calls
.iter()
.any(|call| call.starts_with("codex plugin add ")));
Codex.update(&mut runner).unwrap();

assert!(
runner.called("codex plugin marketplace add braintrustdata/braintrust-codex-plugin")
);
assert!(runner.called("codex plugin add trace-codex@braintrust-codex-plugins"));
}

#[test]
Expand Down
24 changes: 18 additions & 6 deletions bt-daemon/src/trace_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! the coding-agent integrations.

use crate::route::{apply_additional_metadata, apply_tags, resolve_span_plugin_paths};
use crate::trace_command::{DoctorAgent, DoctorArgs, TraceCommand};
use crate::trace_command::{DoctorAgent, DoctorArgs, SetupAgent, TraceCommand};
use crate::wire::{AuthSelection, AuthSource, SessionConfig, SessionRoute, TraceDestination};
use crate::{
braintrust_serve_options, paths, run_disable, run_enable, run_import, run_serve, run_status,
Expand Down Expand Up @@ -495,12 +495,14 @@ fn daemon_warnings(

async fn doctor_output(host: &TraceHostContext, args: DoctorArgs) -> DoctorCommandOutput {
let source = args.agent.identity().id;
let settings = crate::settings::AgentSettings::load(source);
let socket = paths::socket_path(None);
doctor_output_at(
host,
args,
&paths::socket_path(None),
&socket,
paths::agent_settings_path(source, None),
crate::settings::AgentSettings::load(source),
settings,
)
.await
}
Expand Down Expand Up @@ -653,13 +655,23 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul
if !enable_args.plugin.is_empty() {
route.span_plugins = resolve_span_plugin_paths(&enable_args.plugin)?;
}
print_output(run_enable(enable_args, route)?, host.output_format)
let codex = matches!(enable_args.agent, SetupAgent::Codex);
let output = run_enable(enable_args, route)?;
if codex {
crate::codex_hooks::activate().await?;
}
print_output(output, host.output_format)
}
TraceCommand::Disable(disable_args) => {
print_output(run_disable(disable_args.agent)?, host.output_format)
}
TraceCommand::Update(update_args) => {
print_output(crate::run_update(update_args.agent)?, host.output_format)
let codex = matches!(update_args.agent, SetupAgent::Codex);
let output = crate::run_update(update_args.agent)?;
if codex {
crate::codex_hooks::activate().await?;
}
print_output(output, host.output_format)
}
TraceCommand::Daemon(serve_args) => {
init_daemon_logging(host.verbose);
Expand Down Expand Up @@ -764,7 +776,7 @@ async fn run_hook_command(
#[cfg(test)]
mod tests {
use super::*;
use crate::trace_command::{SetupAgent, SetupArgs, StopArgs};
use crate::trace_command::{SetupArgs, StopArgs};
use crate::wire::{BackendAuth, TraceDestination};
use crate::{ImportArgs, ImportSource, RunArgs, RunSource, StatusArgs, TraceCommand};
use std::path::PathBuf;
Expand Down
Loading
Loading