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
98 changes: 97 additions & 1 deletion bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! the output shape to this crate so every front-end reports daemon commands
//! consistently and JSON mode never falls back to human prose.

use crate::wire::StatusResult;
use crate::wire::{SessionRoute, StatusResult};
use serde::Serialize;
use std::path::PathBuf;

Expand Down Expand Up @@ -67,10 +67,41 @@ pub struct StopCommandOutput {
pub stopped: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct AuthDiagnostic {
pub status: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub org_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DoctorCommandOutput {
pub source: String,
pub display_name: String,
pub settings_path: PathBuf,
pub settings_present: bool,
pub enabled: bool,
pub route_source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub route: Option<SessionRoute>,
pub auth: AuthDiagnostic,
pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum TraceCommandOutput {
Status(StatusCommandOutput),
Doctor(Box<DoctorCommandOutput>),
Enable(SetupCommandOutput),
Disable(SetupCommandOutput),
Stop(StopCommandOutput),
Expand All @@ -81,6 +112,10 @@ impl TraceCommandOutput {
Self::Status(status.into())
}

pub fn doctor(output: DoctorCommandOutput) -> Self {
Self::Doctor(Box::new(output))
}

pub fn setup(
source: impl Into<String>,
display_name: impl Into<String>,
Expand Down Expand Up @@ -126,6 +161,38 @@ impl TraceCommandOutput {
uptime_ms: status.uptime_ms.unwrap_or_default(),
sessions: status.sessions.clone(),
})?),
Self::Doctor(doctor) => {
let route = doctor
.route
.as_ref()
.map(serde_json::to_string_pretty)
.transpose()?
.unwrap_or_else(|| "(unresolved)".into());
let mut rendered = format!(
"Braintrust tracing doctor: {}\nEnabled: {}\nSettings: {}{}\nRoute source: {}\nRoute: {}\nAuth: {} ({})",
doctor.display_name,
doctor.enabled,
doctor.settings_path.display(),
if doctor.settings_present { "" } else { " (missing)" },
doctor.route_source,
route,
doctor.auth.status,
doctor.auth.source,
);
if let Some(profile) = &doctor.auth.profile {
rendered.push_str(&format!("\nProfile: {profile}"));
}
if let Some(org_name) = &doctor.auth.org_name {
rendered.push_str(&format!("\nOrganization: {org_name}"));
}
if let Some(error) = &doctor.auth.error {
rendered.push_str(&format!("\nAuth error: {error}"));
}
for warning in &doctor.warnings {
rendered.push_str(&format!("\nWarning: {warning}"));
}
Ok(rendered)
}
Self::Enable(setup) => Ok(format!(
"The Braintrust tracing plugin is installed for {} and configured in {}.\nRestart the coding agent to load the tracing plugin.",
setup.display_name,
Expand Down Expand Up @@ -221,4 +288,33 @@ mod tests {
"No tracing daemon is running."
);
}

#[test]
fn doctor_json_is_structured_and_contains_no_credentials() {
let output = TraceCommandOutput::doctor(DoctorCommandOutput {
source: "codex".into(),
display_name: "Codex".into(),
settings_path: PathBuf::from("/tmp/braintrust.json"),
settings_present: true,
enabled: true,
route_source: "settings_file".into(),
route: Some(SessionRoute::default()),
auth: AuthDiagnostic {
status: "ready".into(),
source: "saved_profile".into(),
kind: Some("oauth".into()),
profile: Some("test-profile".into()),
org_name: Some("test-org".into()),
expires_at_ms: Some(123),
error: None,
},
warnings: Vec::new(),
});
let rendered = output.render(OutputFormat::Json).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["command"], "doctor");
assert_eq!(value["auth"]["source"], "saved_profile");
assert!(!rendered.contains("token"));
assert!(!rendered.contains("api_key"));
}
}
97 changes: 80 additions & 17 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ mod transport;
pub mod wire;
pub use client::HostInfo;
pub use command_output::{
OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput,
AuthDiagnostic, DoctorCommandOutput, OutputFormat, SetupCommandOutput, StatusCommandOutput,
StopCommandOutput, TraceCommandOutput,
};
pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions};
pub use setup::{run_disable, run_enable, run_setup};
pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory};
pub use trace_command::{
DisableArgs, EnableArgs, SetupAgent, SetupArgs, StopArgs, TraceArgs, TraceCommand,
DisableArgs, DoctorAgent, DoctorArgs, EnableArgs, SetupAgent, SetupArgs, StopArgs, TraceArgs,
TraceCommand,
};
pub use trace_runtime::{run_trace, RouteRequirements, TraceHostContext, TraceHostServices};
pub use translate::{
Expand Down Expand Up @@ -391,14 +393,22 @@ pub async fn flush_managed_run(
managed_run_id: &str,
socket: &std::path::Path,
timeout_ms: u64,
) -> anyhow::Result<wire::FlushResult> {
flush_managed_run_in(managed_run_id, socket, timeout_ms, &paths::data_dir(None)).await
}

async fn flush_managed_run_in(
managed_run_id: &str,
socket: &std::path::Path,
timeout_ms: u64,
data_dir: &std::path::Path,
) -> anyhow::Result<wire::FlushResult> {
let stream = match client::connect(socket).await {
Ok(stream) => stream,
Err(_) => {
let accepted_sessions =
journal::read_managed_run_keys(&paths::data_dir(None), managed_run_id)
.await
.len() as u64;
let accepted_sessions = journal::read_managed_run_keys(data_dir, managed_run_id)
.await
.len() as u64;
return Ok(wire::FlushResult {
flushed: true,
pending: 0,
Expand Down Expand Up @@ -510,6 +520,15 @@ pub async fn run_traced(
std::env::var_os(executable_env).unwrap_or_else(|| OsString::from(default_executable));
let injected_args = managed_run_args(args.source, &hook_command)?;
let managed_run_id = uuid::Uuid::new_v4().to_string();
// 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
// the ambient shared daemon.
let isolate_daemon = route.auth.effective_source() == wire::AuthSource::Environment
&& std::env::var_os(paths::SOCKET_ENV).is_none();
let isolated_runtime = isolate_daemon
.then(|| ManagedRunRuntime::new(&managed_run_id))
.transpose()?;
let invocation_settings = serde_json::to_string(&settings::InvocationSettings::enabled(route))?;
let mut command = tokio::process::Command::new(&executable);
command
Expand All @@ -518,6 +537,11 @@ pub async fn run_traced(
.env("_BT_TRACE_MANAGED_RUN", "1")
.env(MANAGED_RUN_ID_ENV, &managed_run_id)
.env(settings::INVOCATION_SETTINGS_ENV, invocation_settings);
if let Some(runtime) = &isolated_runtime {
command
.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",
Expand Down Expand Up @@ -545,8 +569,22 @@ pub async fn run_traced(
}
}
};
let socket = paths::socket_path(None);
match flush_managed_run(&managed_run_id, &socket, MANAGED_RUN_FLUSH_TIMEOUT_MS).await {
let socket = isolated_runtime
.as_ref()
.map(|runtime| runtime.socket.clone())
.unwrap_or_else(|| paths::socket_path(None));
let data_dir = isolated_runtime
.as_ref()
.map(|runtime| runtime.temp_dir.path().to_path_buf())
.unwrap_or_else(|| paths::data_dir(None));
match flush_managed_run_in(
&managed_run_id,
&socket,
MANAGED_RUN_FLUSH_TIMEOUT_MS,
&data_dir,
)
.await
{
Ok(result) if result.accepted_sessions == 0 => tracing::warn!(
managed_run_id,
"managed run produced no accepted trace events"
Expand All @@ -559,9 +597,30 @@ pub async fn run_traced(
),
Err(error) => tracing::warn!(managed_run_id, %error, "managed run trace flush failed"),
}
if isolated_runtime.is_some() {
let _ = shutdown_daemon(&socket).await;
}
status
}

struct ManagedRunRuntime {
temp_dir: tempfile::TempDir,
socket: std::path::PathBuf,
}

impl ManagedRunRuntime {
fn new(_managed_run_id: &str) -> anyhow::Result<Self> {
let temp_dir = tempfile::Builder::new().prefix("bt-trace-run-").tempdir()?;
#[cfg(unix)]
let socket = temp_dir.path().join("daemon.sock");
#[cfg(windows)]
let socket = std::path::PathBuf::from(format!(
r"\\.\pipe\braintrust-bt-daemon-managed-{_managed_run_id}"
));
Ok(Self { temp_dir, socket })
}
}

fn managed_run_args(
source: RunSource,
hook_command: &RunHookCommand,
Expand Down Expand Up @@ -703,18 +762,16 @@ fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec<OsSt
}

fn claude_managed_run_args(command: &str) -> anyhow::Result<Vec<OsString>> {
let hook = serde_json::json!({
let matcher_group = serde_json::json!([{
"hooks": [{
"hooks": [{
"type": "command",
"command": command,
"async": false
}]
"type": "command",
"command": command,
"async": false
}]
});
}]);
let hooks = CLAUDE_RUN_HOOK_EVENTS
.iter()
.map(|event| ((*event).to_string(), hook.clone()))
.map(|event| ((*event).to_string(), matcher_group.clone()))
.collect::<serde_json::Map<_, _>>();
Ok(vec![
OsString::from("--settings"),
Expand Down Expand Up @@ -1140,7 +1197,13 @@ mod tests {
let settings: serde_json::Value = serde_json::from_str(args[1].to_str().unwrap()).unwrap();
let hooks = settings["hooks"].as_object().unwrap();
assert_eq!(hooks.len(), CLAUDE_RUN_HOOK_EVENTS.len());
let command = hooks["SessionStart"]["hooks"][0]["hooks"][0]["command"]
for event in ["SessionStart", "PreToolUse"] {
let matcher_groups = hooks[event].as_array().unwrap();
assert_eq!(matcher_groups.len(), 1);
assert!(matcher_groups[0]["hooks"].is_array());
assert!(matcher_groups[0]["hooks"][0]["hooks"].is_null());
}
let command = hooks["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(command.contains("--managed-run-hook"));
Expand Down
21 changes: 15 additions & 6 deletions bt-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! the crate README's "Dual consumption" section.

use async_trait::async_trait;
use bt_daemon::wire::{AuthSelection, BackendAuth, SessionRoute, TraceDestination};
use bt_daemon::wire::{AuthSelection, AuthSource, BackendAuth, SessionRoute, TraceDestination};
use bt_daemon::{
braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, run_traced,
AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, DebugSinkFactory, HookArgs,
Expand Down Expand Up @@ -45,10 +45,11 @@ impl AuthProvider for EnvironmentAuthProvider {
anyhow::bail!("BRAINTRUST_API_KEY is not set");
}
Ok(AuthLease {
profile: selection
.profile
.clone()
.unwrap_or_else(|| "environment".to_string()),
selection: AuthSelection {
source: AuthSource::Environment,
profile: None,
org_name: selection.org_name.clone(),
},
auth: BackendAuth {
token,
api_url: std::env::var("BRAINTRUST_API_URL").ok(),
Expand Down Expand Up @@ -130,7 +131,15 @@ fn build_route(
parent: Option<braintrust_sdk_rust::SpanComponents>,
) -> SessionRoute {
SessionRoute {
auth: AuthSelection { profile, org_name },
auth: AuthSelection {
source: if profile.is_some() {
AuthSource::SavedProfile
} else {
AuthSource::Environment
},
profile,
org_name,
},
destination: parent
.map(|components| TraceDestination::ParentSpan { components })
.or(destination)
Expand Down
Loading
Loading