From 3f46fd06adfa4da93296317b026aff5966e68062 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 02:23:10 +0800 Subject: [PATCH 1/7] Fix legacy tracing route migration (SDK-271) --- bt-daemon/src/setup.rs | 45 ++++++++++++++++--- .../opencode/content/src/config.test.ts | 22 +++++++++ src/plugins/opencode/content/src/config.ts | 8 ++-- src/plugins/pi/content/src/config.test.ts | 23 ++++++++++ src/plugins/pi/content/src/config.ts | 19 ++++---- 5 files changed, 98 insertions(+), 19 deletions(-) diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index 4792f78..ec05c78 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -338,13 +338,25 @@ fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> route.additional_metadata = settings .get("route") .and_then(|route| route.get("additional_metadata")) + .or_else(|| settings.get("additional_metadata")) .filter(|metadata| metadata.is_object()) .cloned(); } settings.insert("trace_to_braintrust".into(), Value::Bool(true)); settings.insert("route".into(), serde_json::to_value(route)?); - settings.remove("traceToBraintrust"); - settings.remove("project"); + for key in [ + "traceToBraintrust", + "profile", + "org_name", + "api_key", + "api_url", + "app_url", + "project", + "destination", + "additional_metadata", + ] { + settings.remove(key); + } write_object_atomic(path, settings)?; #[cfg(unix)] @@ -637,7 +649,18 @@ mod tests { let path = temp.path().join("braintrust.json"); std::fs::write( &path, - r#"{"traceToBraintrust":false,"project":"old","auth":{"type":"legacy"}}"#, + r#"{ + "traceToBraintrust": false, + "profile": "stale-profile", + "org_name": "stale-org", + "api_key": "stale-secret", + "api_url": "https://stale-api.example", + "app_url": "https://stale-app.example", + "project": "old", + "destination": "old-destination", + "additional_metadata": {"migrated": true}, + "auth": {"type": "legacy"} + }"#, ) .unwrap(); let route = SessionRoute { @@ -662,8 +685,20 @@ mod tests { "coding-agents" ); assert_eq!(settings["auth"]["type"], "legacy"); - assert!(settings.get("traceToBraintrust").is_none()); - assert!(settings.get("project").is_none()); + assert_eq!(settings["route"]["additional_metadata"]["migrated"], true); + for key in [ + "traceToBraintrust", + "profile", + "org_name", + "api_key", + "api_url", + "app_url", + "project", + "destination", + "additional_metadata", + ] { + assert!(settings.get(key).is_none(), "legacy key {key} remained"); + } } #[test] diff --git a/src/plugins/opencode/content/src/config.test.ts b/src/plugins/opencode/content/src/config.test.ts index 90fcecb..e74a606 100644 --- a/src/plugins/opencode/content/src/config.test.ts +++ b/src/plugins/opencode/content/src/config.test.ts @@ -86,6 +86,28 @@ describe("loadConfig", () => { }); }); + it("prefers a canonical route over stale top-level routing", () => { + expect( + loadConfig({ + profile: "stale-profile", + org_name: "stale-org", + project: "stale-project", + route: { + auth: { profile: "route-profile", org_name: "route-org" }, + destination: { type: "project_logs", project_name: "route-project" }, + }, + }), + ).toMatchObject({ + profile: "route-profile", + orgName: "route-org", + projectName: "route-project", + route: { + auth: { profile: "route-profile", org_name: "route-org" }, + destination: { type: "project_logs", project_name: "route-project" }, + }, + }); + }); + it("ignores routing and enablement environment variables, using only the config file", () => { process.env.BRAINTRUST_PROFILE = "personal"; process.env.BRAINTRUST_ORG_NAME = "braintrust"; diff --git a/src/plugins/opencode/content/src/config.ts b/src/plugins/opencode/content/src/config.ts index 2e641d8..416aceb 100644 --- a/src/plugins/opencode/content/src/config.ts +++ b/src/plugins/opencode/content/src/config.ts @@ -70,6 +70,11 @@ export function loadConfig(pluginConfig?: PluginConfig): BraintrustConfig { const destination = pluginConfig.route?.destination as | { type?: unknown; project_name?: unknown } | undefined; + if (pluginConfig.profile) defaults.profile = pluginConfig.profile; + if (pluginConfig.org_name) defaults.orgName = pluginConfig.org_name; + if (pluginConfig.project) defaults.projectName = pluginConfig.project; + // A nested route is canonical. Top-level fields are a compatibility + // fallback only for files that `bt trace enable` has not migrated yet. if (auth?.profile) defaults.profile = auth.profile; if (auth?.org_name) defaults.orgName = auth.org_name; if ( @@ -79,9 +84,6 @@ export function loadConfig(pluginConfig?: PluginConfig): BraintrustConfig { ) { defaults.projectName = destination.project_name; } - if (pluginConfig.profile) defaults.profile = pluginConfig.profile; - if (pluginConfig.org_name) defaults.orgName = pluginConfig.org_name; - if (pluginConfig.project) defaults.projectName = pluginConfig.project; if (pluginConfig.trace_to_braintrust !== undefined) { defaults.tracingEnabled = pluginConfig.trace_to_braintrust; } diff --git a/src/plugins/pi/content/src/config.test.ts b/src/plugins/pi/content/src/config.test.ts index 0cf543d..248768d 100644 --- a/src/plugins/pi/content/src/config.test.ts +++ b/src/plugins/pi/content/src/config.test.ts @@ -95,6 +95,29 @@ describe("loadConfig", () => { }); }); + it("prefers a canonical route over stale top-level routing", () => { + writeJson(join(home, ".pi", "agent", "braintrust.json"), { + trace_to_braintrust: true, + profile: "stale-profile", + org_name: "stale-org", + project: "stale-project", + route: { + auth: { profile: "route-profile", org_name: "route-org" }, + destination: { type: "project_logs", project_name: "route-project" }, + }, + }); + + expect(loadConfig(cwd)).toMatchObject({ + profile: "route-profile", + orgName: "route-org", + projectName: "route-project", + route: { + auth: { profile: "route-profile", org_name: "route-org" }, + destination: { type: "project_logs", project_name: "route-project" }, + }, + }); + }); + it("ignores routing and enablement environment variables, using only the config file", () => { writeJson(join(home, ".pi", "agent", "braintrust.json"), { trace_to_braintrust: false, diff --git a/src/plugins/pi/content/src/config.ts b/src/plugins/pi/content/src/config.ts index a1e9cd4..72deceb 100644 --- a/src/plugins/pi/content/src/config.ts +++ b/src/plugins/pi/content/src/config.ts @@ -65,22 +65,19 @@ function applyConfig(config: PiConfig, source: ConfigRecord | undefined): void { const route = record(source.route) as DaemonSessionRoute | undefined; if (route?.destination !== undefined) config.route = route; const destination = record(route?.destination); - config.profile = nonEmptyString(route?.auth?.profile) ?? config.profile; - config.orgName = nonEmptyString(route?.auth?.org_name) ?? config.orgName; - if (destination?.type === "project_logs") { - config.projectName = nonEmptyString(destination.project_name) ?? config.projectName; - } - config.profile = nonEmptyString(source.profile) ?? config.profile; - config.orgName = nonEmptyString(source.org_name) ?? config.orgName; - config.projectName = nonEmptyString(source.project) ?? config.projectName; + const profile = nonEmptyString(route?.auth?.profile) ?? nonEmptyString(source.profile); + const orgName = nonEmptyString(route?.auth?.org_name) ?? nonEmptyString(source.org_name); + const routeProject = + destination?.type === "project_logs" ? nonEmptyString(destination.project_name) : undefined; + const projectName = routeProject ?? nonEmptyString(source.project); + config.profile = profile ?? config.profile; + config.orgName = orgName ?? config.orgName; + config.projectName = projectName ?? config.projectName; config.enabled = boolean(source.trace_to_braintrust) ?? config.enabled; config.additionalMetadata = record(source.additional_metadata) ?? config.additionalMetadata; config.showUi = boolean(source.show_ui) ?? config.showUi; config.showTraceLink = boolean(source.show_trace_link) ?? config.showTraceLink; - const profile = nonEmptyString(source.profile); - const orgName = nonEmptyString(source.org_name); - const projectName = nonEmptyString(source.project); const additionalMetadata = record(source.additional_metadata); if (profile || orgName) { config.route.auth = { From 0d46f55cc4b373f30eac88c5f29b295aff288872 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 02:23:49 +0800 Subject: [PATCH 2/7] Fix Claude managed-run hook settings (SDK-283) --- bt-daemon/src/lib.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 762a8ad..14cd163 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -703,18 +703,16 @@ fn codex_managed_run_args(unix_command: &str, windows_command: &str) -> Vec anyhow::Result> { - 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::>(); Ok(vec![ OsString::from("--settings"), @@ -1140,7 +1138,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")); From 65683c64d753658451c0c4bf0fb0c8aaf38f591f Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 02:24:13 +0800 Subject: [PATCH 3/7] Fix OpenCode log data uploads (SDK-287) --- .../opencode/content/src/tools/bt-cli.test.ts | 21 +++++++++++++++++++ .../opencode/content/src/tools/bt-cli.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/plugins/opencode/content/src/tools/bt-cli.test.ts b/src/plugins/opencode/content/src/tools/bt-cli.test.ts index 5bf7a79..f12c0a2 100644 --- a/src/plugins/opencode/content/src/tools/bt-cli.test.ts +++ b/src/plugins/opencode/content/src/tools/bt-cli.test.ts @@ -65,8 +65,10 @@ describe("BtCliToolsClient", () => { it("delegates manual logs through bt sync push and removes the temporary input", async () => { let inputPath = ""; let inputContents = ""; + let syncArgs: string[] = []; const client = new BtCliToolsClient(config, async (args) => { if (args[0] === "projects") return '[{"id":"project-1","name":"agents"}]'; + syncArgs = args; inputPath = args[args.indexOf("--in") + 1] ?? ""; inputContents = readFileSync(inputPath, "utf8"); return '{"uploaded_rows":1}'; @@ -81,6 +83,25 @@ describe("BtCliToolsClient", () => { }); expect(id).toBe("row-1"); + expect(syncArgs).toEqual([ + "sync", + "push", + "project_logs:agents", + "--in", + inputPath, + "--root", + expect.stringContaining("bt-opencode-tools-"), + "--force", + "--json", + "--no-input", + "--prefer-profile", + "--profile", + "work", + "--org", + "acme", + "--project", + "agents", + ]); expect(JSON.parse(inputContents)).toMatchObject({ id: "row-1", input: "hello" }); expect(inputPath).toContain("bt-opencode-tools-"); expect(existsSync(inputPath)).toBe(false); diff --git a/src/plugins/opencode/content/src/tools/bt-cli.ts b/src/plugins/opencode/content/src/tools/bt-cli.ts index eaf7c41..0f43d1c 100644 --- a/src/plugins/opencode/content/src/tools/bt-cli.ts +++ b/src/plugins/opencode/content/src/tools/bt-cli.ts @@ -114,7 +114,7 @@ export class BtCliToolsClient { input, "--root", directory, - "--fresh", + "--force", ...this.selection(true), ]); return data.id; From 3bb3f35fa14d8218e90e5ba6aafae4f4efbbd0e0 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 02:24:46 +0800 Subject: [PATCH 4/7] Document Claude Cowork support boundaries (SDK-285 SDK-286) --- src/plugins/claude/content/README.md | 11 +++++++++++ .../skills/troubleshoot-braintrust-mcp/SKILL.md | 11 +++++++++++ .../content/plugins/trace-claude-code/README.md | 7 +++++++ 3 files changed, 29 insertions(+) diff --git a/src/plugins/claude/content/README.md b/src/plugins/claude/content/README.md index 8d3dfed..3b8aa1d 100644 --- a/src/plugins/claude/content/README.md +++ b/src/plugins/claude/content/README.md @@ -12,6 +12,17 @@ A Claude Code plugin marketplace for [Braintrust](https://braintrust.dev) integr - A [Braintrust account](https://braintrust.dev) - The `bt` CLI, authenticated with `bt login` +## Supported Claude surfaces + +These marketplace plugins support Claude Code CLI and Claude Code mode in the +desktop app. They do not currently support the Cowork tab, which runs tools and +hooks inside a separate VM without the host's `bt` installation, Braintrust +configuration, or environment variables. + +In Cowork, use the Braintrust connector provided through Claude for MCP access. +The connector is separate from the `braintrust` marketplace plugin. Automatic +Cowork session tracing is not currently supported. + ## Installation Add the marketplace: diff --git a/src/plugins/claude/content/plugins/braintrust/skills/troubleshoot-braintrust-mcp/SKILL.md b/src/plugins/claude/content/plugins/braintrust/skills/troubleshoot-braintrust-mcp/SKILL.md index 24f566e..e4e582a 100644 --- a/src/plugins/claude/content/plugins/braintrust/skills/troubleshoot-braintrust-mcp/SKILL.md +++ b/src/plugins/claude/content/plugins/braintrust/skills/troubleshoot-braintrust-mcp/SKILL.md @@ -7,6 +7,17 @@ version: 1.0.0 This Claude plugin automatically sets up a Braintrust MCP connection. The connection reads the `BRAINTRUST_API_KEY` environment variable to establish the MCP connection. +## Check the Claude surface first + +This plugin MCP configuration supports Claude Code CLI and Claude Code mode in +the desktop app. It does not configure MCP inside the Cowork tab because Cowork +runs in a separate VM without the host environment variables or network +context expected by `.mcp.json`. + +If the user is in Cowork, stop these plugin troubleshooting steps and direct +them to add or use the Braintrust connector through Claude. Do not ask them to +copy `BRAINTRUST_API_KEY` into the Cowork VM. + ## Troubleshooting steps ### 1. Verify the environment variable is set diff --git a/src/plugins/claude/content/plugins/trace-claude-code/README.md b/src/plugins/claude/content/plugins/trace-claude-code/README.md index 41a0d72..2ec5f8d 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/README.md +++ b/src/plugins/claude/content/plugins/trace-claude-code/README.md @@ -12,6 +12,13 @@ already available, then forwards the event. The plugin is credential-free and fail-open; the `bt` CLI and shared daemon own authentication, event journaling, trace construction, and delivery. +## Supported surfaces + +This plugin supports Claude Code CLI and Claude Code mode in the desktop app. +It does not support the Cowork tab. Cowork executes hooks in a separate VM that +does not inherit the host's `bt` binary, saved Braintrust route, or credentials, +so installing this plugin there does not enable tracing. + To add fields to each root trace span, pass a JSON object (as `--additional-metadata` or `BRAINTRUST_ADDITIONAL_METADATA`) to `bt trace enable claude` for a persistent configuration, or to From b4cc5e78b464c6b6cf16cf7fb8b15f3a5e7b72df Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 02:27:04 +0800 Subject: [PATCH 5/7] Add tracing configuration diagnostics (SDK-273) --- bt-daemon/src/command_output.rs | 98 +++++++++++++++++++++++- bt-daemon/src/lib.rs | 6 +- bt-daemon/src/trace_command.rs | 57 +++++++++++++- bt-daemon/src/trace_runtime.rs | 128 +++++++++++++++++++++++++++++++- 4 files changed, 281 insertions(+), 8 deletions(-) diff --git a/bt-daemon/src/command_output.rs b/bt-daemon/src/command_output.rs index 704160d..11b7b41 100644 --- a/bt-daemon/src/command_output.rs +++ b/bt-daemon/src/command_output.rs @@ -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; @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub org_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[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, + pub auth: AuthDiagnostic, + pub warnings: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(tag = "command", rename_all = "snake_case")] pub enum TraceCommandOutput { Status(StatusCommandOutput), + Doctor(Box), Enable(SetupCommandOutput), Disable(SetupCommandOutput), Stop(StopCommandOutput), @@ -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, display_name: impl Into, @@ -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, @@ -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")); + } } diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 14cd163..b436303 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -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::{ diff --git a/bt-daemon/src/trace_command.rs b/bt-daemon/src/trace_command.rs index 235ed44..67fed8a 100644 --- a/bt-daemon/src/trace_command.rs +++ b/bt-daemon/src/trace_command.rs @@ -5,7 +5,7 @@ //! dispatch these commands without duplicating agent-specific CLI knowledge. use crate::{HookArgs, ImportArgs, RunArgs, ServeArgs, StatusArgs}; -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Debug, Clone, Args)] @@ -33,6 +33,8 @@ pub enum TraceCommand { /// Print daemon/session status. #[command(hide = true)] Status(StatusArgs), + /// Explain the effective tracing configuration for a coding agent. + Doctor(DoctorArgs), /// Gracefully stop the tracing daemon. #[command(hide = true)] Stop(StopArgs), @@ -49,6 +51,43 @@ pub struct StopArgs { pub socket: Option, } +#[derive(Debug, Clone, Args)] +pub struct DoctorArgs { + /// Coding agent whose tracing configuration should be inspected. + #[arg(value_enum)] + pub agent: DoctorAgent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum DoctorAgent { + Codex, + #[value(name = "claude", alias = "claude-code")] + Claude, + #[value(name = "opencode", alias = "open-code")] + OpenCode, + Pi, +} + +impl DoctorAgent { + pub(crate) fn source(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + Self::OpenCode => "opencode", + Self::Pi => "pi", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Codex => "Codex", + Self::Claude => "Claude Code", + Self::OpenCode => "OpenCode", + Self::Pi => "Pi", + } + } +} + #[derive(Debug, Clone, Args)] pub struct EnableArgs { #[command(subcommand)] @@ -168,4 +207,20 @@ mod tests { }) if value == r#"{"import":true}"# )); } + + #[test] + fn doctor_accepts_every_supported_agent_alias() { + for (agent, expected) in [ + ("codex", DoctorAgent::Codex), + ("claude-code", DoctorAgent::Claude), + ("open-code", DoctorAgent::OpenCode), + ("pi", DoctorAgent::Pi), + ] { + let cli = Cli::try_parse_from(["bt", "doctor", agent]).unwrap(); + assert!(matches!( + cli.trace.command, + TraceCommand::Doctor(DoctorArgs { agent }) if agent == expected + )); + } + } } diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index 39a8731..e33c001 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -5,13 +5,14 @@ //! behavior, setup, managed runs, imports, and output contracts stay here with //! the coding-agent integrations. -use crate::trace_command::TraceCommand; +use crate::trace_command::{DoctorArgs, TraceCommand}; use crate::wire::{AuthSelection, SessionConfig, SessionRoute}; use crate::{ apply_additional_metadata, braintrust_serve_options, paths, run_disable, run_enable, run_hook, - run_import, run_serve, run_status, run_traced, shutdown_daemon, AuthLease, AuthProvider, - AuthResolveReason, BraintrustSinkConfig, HostInfo, OutputFormat, Registry, RunHookCommand, - ServeOptions, StatusArgs, TraceArgs, TraceCommandOutput, + run_import, run_serve, run_status, run_traced, shutdown_daemon, AuthDiagnostic, AuthLease, + AuthProvider, AuthResolveReason, BraintrustSinkConfig, DoctorCommandOutput, HostInfo, + OutputFormat, Registry, RunHookCommand, ServeOptions, StatusArgs, TraceArgs, + TraceCommandOutput, }; use async_trait::async_trait; use std::ffi::OsString; @@ -48,6 +49,43 @@ pub trait TraceHostServices: Send + Sync { selection: &AuthSelection, reason: AuthResolveReason, ) -> anyhow::Result; + + /// Describe the selected auth without exposing credentials. Hosts may + /// override this to report exact profile kind and provenance without + /// refreshing a credential. + async fn diagnose_auth(&self, selection: &AuthSelection) -> AuthDiagnostic { + match self + .resolve_auth(selection, AuthResolveReason::Initial) + .await + { + Ok(lease) => AuthDiagnostic { + status: "ready".into(), + source: if selection.profile.is_some() { + "saved_profile".into() + } else { + "command_context".into() + }, + kind: None, + profile: Some(lease.profile), + org_name: lease.auth.org_name, + expires_at_ms: lease.expires_at_ms, + error: None, + }, + Err(error) => AuthDiagnostic { + status: "error".into(), + source: if selection.profile.is_some() { + "saved_profile".into() + } else { + "command_context".into() + }, + kind: None, + profile: selection.profile.clone(), + org_name: selection.org_name.clone(), + expires_at_ms: None, + error: Some(error.to_string()), + }, + } + } } /// Everything the plugin runtime needs from its embedding CLI. @@ -185,6 +223,84 @@ fn print_output(output: TraceCommandOutput, format: OutputFormat) -> anyhow::Res Ok(()) } +async fn doctor_output(host: &TraceHostContext, args: DoctorArgs) -> DoctorCommandOutput { + let source = args.agent.source(); + let settings_path = paths::agent_settings_path(source, None); + let settings_present = settings_path.exists(); + let settings = crate::settings::AgentSettings::load(source); + let enabled = settings.tracing_enabled(); + let mut warnings = Vec::new(); + + if !settings_present { + warnings.push(format!( + "settings file is missing; run `bt trace enable {source}`" + )); + } else if !enabled { + warnings.push(format!( + "tracing is disabled; run `bt trace enable {source}`" + )); + } + + let (route, route_source) = match settings.route { + Some(route) => (Some(route), "settings_file".to_string()), + None => match host + .services + .resolve_route(RouteRequirements::default()) + .await + { + Ok(route) => (Some(route), "command_context".to_string()), + Err(error) => { + warnings.push(format!("route could not be resolved: {error}")); + (None, "unresolved".to_string()) + } + }, + }; + + if route + .as_ref() + .is_some_and(|route| route.destination.is_none()) + { + warnings.push("trace destination is not configured".into()); + } + if route + .as_ref() + .and_then(|route| route.auth.profile.as_deref()) + == Some("environment") + { + warnings.push( + "profile `environment` is not a saved login; rerun setup with --profile ".into(), + ); + } + + let auth = match &route { + Some(route) => host.services.diagnose_auth(&route.auth).await, + None => AuthDiagnostic { + status: "unresolved".into(), + source: "unresolved".into(), + kind: None, + profile: None, + org_name: None, + expires_at_ms: None, + error: Some("route is unresolved".into()), + }, + }; + if let Some(error) = &auth.error { + warnings.push(format!("authentication is unusable: {error}")); + } + + DoctorCommandOutput { + source: source.into(), + display_name: args.agent.display_name().into(), + settings_path, + settings_present, + enabled, + route_source, + route, + auth, + warnings, + } +} + /// Execute the complete mounted trace command. pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Result<()> { match args.command { @@ -226,6 +342,10 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul TraceCommandOutput::status(run_status(status_args).await?), host.output_format, ), + TraceCommand::Doctor(doctor_args) => print_output( + TraceCommandOutput::doctor(doctor_output(&host, doctor_args).await), + host.output_format, + ), TraceCommand::Stop(stop_args) => { let socket = paths::socket_path(stop_args.socket.as_deref()); let status_args = StatusArgs { From f4c9dc0c574ab21bb3999750d592bb4cdced8ff6 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 26 Aug 2026 22:19:42 +0800 Subject: [PATCH 6/7] Represent invocation environment auth explicitly (SDK-272) --- bt-daemon/src/lib.rs | 69 ++++++++++++++++++++++++++--- bt-daemon/src/main.rs | 21 ++++++--- bt-daemon/src/server.rs | 43 ++++++++---------- bt-daemon/src/settings.rs | 1 + bt-daemon/src/setup.rs | 1 + bt-daemon/src/trace_runtime.rs | 72 +++++++++++++++++++----------- bt-daemon/src/wire/envelope.rs | 79 +++++++++++++++++++++++++++++++-- bt-daemon/src/wire/mod.rs | 4 +- bt-daemon/tests/pipeline.rs | 81 ++++++++++++++++++++++++++++++---- 9 files changed, 296 insertions(+), 75 deletions(-) diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index b436303..0ef36ae 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -393,14 +393,22 @@ pub async fn flush_managed_run( managed_run_id: &str, socket: &std::path::Path, timeout_ms: u64, +) -> anyhow::Result { + 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 { 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, @@ -512,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 @@ -520,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", @@ -547,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" @@ -561,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 { + 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, diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index c62883b..64ba8e7 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -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, @@ -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(), @@ -130,7 +131,15 @@ fn build_route( parent: Option, ) -> 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) diff --git a/bt-daemon/src/server.rs b/bt-daemon/src/server.rs index b9e58b7..7192ae7 100644 --- a/bt-daemon/src/server.rs +++ b/bt-daemon/src/server.rs @@ -42,11 +42,12 @@ pub enum AuthResolveReason { Unauthorized, } -/// A live credential lease. Only the canonical profile name is observable; -/// the backend credential remains in daemon memory and is never journaled. +/// A live credential lease. Only the canonical non-secret selection is +/// observable; the backend credential remains in daemon memory and is never +/// journaled. #[derive(Debug, Clone)] pub struct AuthLease { - pub profile: String, + pub selection: AuthSelection, pub auth: BackendAuth, /// Epoch milliseconds. `None` is appropriate for non-expiring API keys. pub expires_at_ms: Option, @@ -54,9 +55,9 @@ pub struct AuthLease { #[async_trait] pub trait AuthProvider: Send + Sync { - /// Resolve without prompting. On refresh, `selection.profile` is the - /// canonical profile returned by the initial lease, keeping an active - /// session pinned even if the user's default profile changes. + /// Resolve without prompting. On refresh, `selection` is the canonical + /// source returned by the initial lease, keeping an active session pinned + /// even if the user's default profile or environment changes. async fn resolve( &self, selection: &AuthSelection, @@ -140,7 +141,7 @@ impl Daemon { ); } let key = DeliveryKey::new(&env.session_id, &route)?; - let (selection, reason, expected_profile) = { + let (selection, reason, expected_selection) = { let states = self.session_auth.lock().await; match states.get(&key) { Some(state) => { @@ -149,12 +150,9 @@ impl Daemon { return Ok(key); } ( - AuthSelection { - profile: Some(state.lease.profile.clone()), - org_name: state.lease.auth.org_name.clone(), - }, + state.lease.selection.clone(), AuthResolveReason::Expiring, - Some(state.lease.profile.clone()), + Some(state.lease.selection.clone()), ) } None => (route.auth.clone(), AuthResolveReason::Initial, None), @@ -163,7 +161,7 @@ impl Daemon { let lease = provider.resolve(&selection, reason).await.map_err(|error| { let message = format!( - "could not resolve Braintrust profile for {}: {error}; run `bt auth login` or select a profile explicitly", + "could not resolve Braintrust auth for {}: {error}; run `bt login` or select a profile explicitly", env.source ); self.auth_errors.lock().unwrap().insert( @@ -172,11 +170,11 @@ impl Daemon { ); anyhow::anyhow!(message) })?; - if let Some(expected) = expected_profile { - if lease.profile != expected { + if let Some(expected) = expected_selection { + if lease.selection != expected { anyhow::bail!( - "credential refresh changed profile from {expected:?} to {:?}", - lease.profile + "credential refresh changed auth selection from {expected:?} to {:?}", + lease.selection ); } } @@ -184,7 +182,7 @@ impl Daemon { if lease.auth.org_name.as_deref() != Some(expected_org) { anyhow::bail!( "profile {:?} resolved organization {:?}, expected {:?}", - lease.profile, + lease.selection, lease.auth.org_name, expected_org ); @@ -210,15 +208,12 @@ impl Daemon { if !lease_is_expiring(&state.lease) { return Ok(()); } - let selection = AuthSelection { - profile: Some(state.lease.profile.clone()), - org_name: state.lease.auth.org_name.clone(), - }; + let selection = state.lease.selection.clone(); let lease = provider .resolve(&selection, AuthResolveReason::Expiring) .await?; - if lease.profile != state.lease.profile { - anyhow::bail!("credential refresh changed the session profile"); + if lease.selection != state.lease.selection { + anyhow::bail!("credential refresh changed the session auth selection"); } let config = state.route.with_auth(lease.auth.clone()); self.session_auth.lock().await.insert( diff --git a/bt-daemon/src/settings.rs b/bt-daemon/src/settings.rs index dda7a9e..4aa98f1 100644 --- a/bt-daemon/src/settings.rs +++ b/bt-daemon/src/settings.rs @@ -163,6 +163,7 @@ mod tests { let invocation = |profile: &str, project: &str| { serde_json::to_string(&InvocationSettings::enabled(SessionRoute { auth: crate::wire::AuthSelection { + source: crate::wire::AuthSource::SavedProfile, profile: Some(profile.to_string()), org_name: Some(format!("{profile}-org")), }, diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index ec05c78..623983e 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -665,6 +665,7 @@ mod tests { .unwrap(); let route = SessionRoute { auth: AuthSelection { + source: crate::wire::AuthSource::SavedProfile, profile: Some("work".into()), org_name: Some("Braintrust SDKs".into()), }, diff --git a/bt-daemon/src/trace_runtime.rs b/bt-daemon/src/trace_runtime.rs index e33c001..3afa8bd 100644 --- a/bt-daemon/src/trace_runtime.rs +++ b/bt-daemon/src/trace_runtime.rs @@ -6,7 +6,7 @@ //! the coding-agent integrations. use crate::trace_command::{DoctorArgs, TraceCommand}; -use crate::wire::{AuthSelection, SessionConfig, SessionRoute}; +use crate::wire::{AuthSelection, AuthSource, SessionConfig, SessionRoute}; use crate::{ apply_additional_metadata, braintrust_serve_options, paths, run_disable, run_enable, run_hook, run_import, run_serve, run_status, run_traced, shutdown_daemon, AuthDiagnostic, AuthLease, @@ -29,6 +29,18 @@ pub struct RouteRequirements { /// organizations when the profile has no default. Hooks leave this false /// so an agent's turn never blocks on interactive input. pub interactive_auth: bool, + /// The route will be persisted and replayed by future agent processes, so + /// it must use durable saved-profile credentials rather than depending on + /// the current process environment. + pub persistent_auth: bool, +} + +fn auth_source_label(selection: &AuthSelection) -> &'static str { + match selection.effective_source() { + AuthSource::SavedProfile => "saved_profile", + AuthSource::Environment => "environment", + AuthSource::Auto => "command_context", + } } /// Host-owned services used by the integration runtime. @@ -60,24 +72,16 @@ pub trait TraceHostServices: Send + Sync { { Ok(lease) => AuthDiagnostic { status: "ready".into(), - source: if selection.profile.is_some() { - "saved_profile".into() - } else { - "command_context".into() - }, + source: auth_source_label(&lease.selection).into(), kind: None, - profile: Some(lease.profile), + profile: lease.selection.profile, org_name: lease.auth.org_name, expires_at_ms: lease.expires_at_ms, error: None, }, Err(error) => AuthDiagnostic { status: "error".into(), - source: if selection.profile.is_some() { - "saved_profile".into() - } else { - "command_context".into() - }, + source: auth_source_label(selection).into(), kind: None, profile: selection.profile.clone(), org_name: selection.org_name.clone(), @@ -213,7 +217,7 @@ async fn resolve_command_route( .resolve_auth(&route.auth, AuthResolveReason::Initial) .await?; let org_name = require_resolved_org(&route, &lease)?; - route.auth.profile = Some(lease.profile); + route.auth = lease.selection; route.auth.org_name = Some(org_name); Ok(route) } @@ -310,6 +314,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul RouteRequirements { destination_required: true, interactive_auth: true, + persistent_auth: true, }, ) .await?; @@ -365,6 +370,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul destination_required: import_args.destination.is_none() && import_args.parent.is_none(), interactive_auth: true, + persistent_auth: false, }) .await?; apply_additional_metadata(&mut route, import_args.additional_metadata.as_deref())?; @@ -377,6 +383,7 @@ pub async fn run_trace(args: TraceArgs, host: TraceHostContext) -> anyhow::Resul RouteRequirements { destination_required: true, interactive_auth: true, + persistent_auth: false, }, ) .await?; @@ -499,7 +506,11 @@ mod tests { anyhow::bail!(error); } Ok(AuthLease { - profile: "test".into(), + selection: AuthSelection { + source: AuthSource::SavedProfile, + profile: Some("test".into()), + org_name: self.resolved_org.map(str::to_string), + }, auth: BackendAuth { token: "secret".into(), api_url: None, @@ -528,20 +539,27 @@ mod tests { const COMMAND_REQUIREMENTS: RouteRequirements = RouteRequirements { destination_required: true, interactive_auth: true, + persistent_auth: false, }; #[tokio::test] async fn setup_and_run_require_a_host_resolved_destination() { - for command in [ - TraceCommand::Setup(SetupArgs { - agent: SetupAgent::OpenCode, - additional_metadata: None, - }), - TraceCommand::Run(RunArgs { - source: RunSource::Codex, - additional_metadata: None, - agent_args: Vec::new(), - }), + for (command, persistent_auth) in [ + ( + TraceCommand::Setup(SetupArgs { + agent: SetupAgent::OpenCode, + additional_metadata: None, + }), + true, + ), + ( + TraceCommand::Run(RunArgs { + source: RunSource::Codex, + additional_metadata: None, + agent_args: Vec::new(), + }), + false, + ), ] { let services = Arc::new(RecordingHost::new(Some("no destination"), None)); let error = run_trace(TraceArgs { command }, test_host(services.clone())) @@ -550,7 +568,10 @@ mod tests { assert_eq!(error.to_string(), "no destination"); assert_eq!( *services.route_requests.lock().unwrap(), - [COMMAND_REQUIREMENTS] + [RouteRequirements { + persistent_auth, + ..COMMAND_REQUIREMENTS + }] ); } } @@ -624,6 +645,7 @@ mod tests { [RouteRequirements { destination_required, interactive_auth: true, + persistent_auth: false, }] ); } diff --git a/bt-daemon/src/wire/envelope.rs b/bt-daemon/src/wire/envelope.rs index f61cc4f..9f7ef73 100644 --- a/bt-daemon/src/wire/envelope.rs +++ b/bt-daemon/src/wire/envelope.rs @@ -40,17 +40,65 @@ pub struct Envelope { pub config: Option, } -/// Non-secret profile selection. A profile identifies the stored Braintrust -/// user credentials; an optional organization constrains profiles that can -/// address more than one organization. +/// The non-secret credential source selected for a session route. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthSource { + /// Resolve using the host's ordinary precedence rules. Retained for old + /// profile-less routes; newly resolved command routes are canonicalized. + #[default] + Auto, + /// Resolve a named credential from the host's saved profile store. + SavedProfile, + /// Resolve BRAINTRUST_API_KEY from the process environment at delivery + /// time. The key itself is never serialized into the route. + Environment, +} + +/// Non-secret auth selection. An optional organization constrains credentials +/// that can address more than one organization. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct AuthSelection { + #[serde(default, skip_serializing_if = "AuthSource::is_auto")] + pub source: AuthSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub org_name: Option, } +impl AuthSource { + fn is_auto(&self) -> bool { + *self == Self::Auto + } +} + +impl AuthSelection { + /// Interpret old `profile: "environment"` routes as environment auth + /// without preserving the synthetic value as a saved profile name. + pub fn effective_source(&self) -> AuthSource { + match self.source { + AuthSource::Auto if self.profile.as_deref() == Some("environment") => { + AuthSource::Environment + } + AuthSource::Auto if self.profile.is_some() => AuthSource::SavedProfile, + source => source, + } + } + + pub fn canonicalized(mut self) -> anyhow::Result { + self.source = self.effective_source(); + match self.source { + AuthSource::SavedProfile if self.profile.is_none() => { + anyhow::bail!("saved-profile auth requires a profile name") + } + AuthSource::Environment => self.profile = None, + AuthSource::Auto | AuthSource::SavedProfile => {} + } + Ok(self) + } +} + /// Immutable, journal-safe routing and trace settings for one agent session. /// Credentials are deliberately absent and are resolved inside the daemon. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -244,6 +292,7 @@ mod tests { payload: serde_json::json!({ "session_id": "sess-1", "tool_name": "shell" }), route: Some(SessionRoute { auth: AuthSelection { + source: AuthSource::SavedProfile, profile: Some("work".into()), org_name: Some("acme".into()), }, @@ -297,6 +346,7 @@ mod tests { fn route_is_journal_safe_and_builds_resolved_config() { let route = SessionRoute { auth: AuthSelection { + source: AuthSource::SavedProfile, profile: Some("work".into()), org_name: Some("acme".into()), }, @@ -328,6 +378,29 @@ mod tests { assert!(!journal.contains("secret")); } + #[test] + fn environment_auth_serializes_without_a_profile_or_secret() { + let selection = AuthSelection { + source: AuthSource::Environment, + profile: None, + org_name: Some("acme".into()), + }; + let json = serde_json::to_string(&selection).unwrap(); + assert_eq!(json, r#"{"source":"environment","org_name":"acme"}"#); + assert!(!json.contains("BRAINTRUST_API_KEY")); + } + + #[test] + fn legacy_environment_profile_canonicalizes_to_environment_auth() { + let selection: AuthSelection = + serde_json::from_str(r#"{"profile":"environment","org_name":"acme"}"#).unwrap(); + assert_eq!(selection.effective_source(), AuthSource::Environment); + let canonical = selection.canonicalized().unwrap(); + assert_eq!(canonical.source, AuthSource::Environment); + assert_eq!(canonical.profile, None); + assert_eq!(canonical.org_name.as_deref(), Some("acme")); + } + #[test] fn flush_mode_defaults_to_fire_and_forget() { let json = serde_json::json!({ diff --git a/bt-daemon/src/wire/mod.rs b/bt-daemon/src/wire/mod.rs index 289222b..7e112ad 100644 --- a/bt-daemon/src/wire/mod.rs +++ b/bt-daemon/src/wire/mod.rs @@ -10,8 +10,8 @@ mod methods; mod rpc; pub use envelope::{ - AuthSelection, BackendAuth, Envelope, FlushMode, RedactedEnvelope, SessionConfig, SessionRoute, - TraceDestination, + AuthSelection, AuthSource, BackendAuth, Envelope, FlushMode, RedactedEnvelope, SessionConfig, + SessionRoute, TraceDestination, }; pub use methods::{ method, Capabilities, ClientInfo, EventLogResult, FlushParams, FlushResult, InitializeParams, diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index 44bbeae..4298dc3 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -3,7 +3,9 @@ //! spawning, so it's deterministic). use async_trait::async_trait; -use bt_daemon::wire::{AuthSelection, BackendAuth, Envelope, SessionConfig, SessionRoute}; +use bt_daemon::wire::{ + AuthSelection, AuthSource, BackendAuth, Envelope, SessionConfig, SessionRoute, +}; use bt_daemon::{ debug_serve_options, flush_managed_run, flush_session, forward_envelope, run_serve, run_status, shutdown_daemon, AuthLease, AuthProvider, AuthResolveReason, HostInfo, Registry, ServeArgs, @@ -148,6 +150,7 @@ fn routed_envelope(session_id: &str, profile: &str, org: &str, event: &str) -> E let mut env = envelope(session_id, event, 1); env.route = Some(SessionRoute { auth: AuthSelection { + source: AuthSource::SavedProfile, profile: Some(profile.into()), org_name: Some(org.into()), }, @@ -160,6 +163,23 @@ fn routed_envelope(session_id: &str, profile: &str, org: &str, event: &str) -> E env } +fn environment_routed_envelope(session_id: &str, org: &str, event: &str) -> Envelope { + let mut env = envelope(session_id, event, 1); + env.route = Some(SessionRoute { + auth: AuthSelection { + source: AuthSource::Environment, + profile: None, + org_name: Some(org.into()), + }, + destination: Some(bt_daemon::wire::TraceDestination::ProjectLogs { + project_id: None, + project_name: Some("environment-traces".into()), + }), + ..SessionRoute::default() + }); + env +} + struct TestAuthProvider { calls: Mutex>, fail: bool, @@ -180,23 +200,24 @@ impl AuthProvider for TestAuthProvider { if self.fail { anyhow::bail!("profile credential unavailable") } - let profile = selection + let canonical = selection.clone().canonicalized()?; + let credential_name = canonical .profile .clone() - .unwrap_or_else(|| "default".into()); + .unwrap_or_else(|| "environment".into()); Ok(AuthLease { - profile: profile.clone(), + selection: canonical.clone(), auth: BackendAuth { - token: format!("secret-{profile}-{call_index}"), - api_url: Some(format!("https://{profile}.example.test")), + token: format!("secret-{credential_name}-{call_index}"), + api_url: Some(format!("https://{credential_name}.example.test")), app_url: None, // Model a profile with a default organization for selections // that do not constrain one. org_name: selection .org_name .clone() - .or_else(|| Some(format!("{profile}-org"))), - org_id: Some(format!("org-{profile}")), + .or_else(|| Some(format!("{credential_name}-org"))), + org_id: Some(format!("org-{credential_name}")), }, expires_at_ms: (self.first_lease_expired && call_index == 1).then_some(0), }) @@ -459,6 +480,44 @@ async fn routed_sessions_resolve_multiple_profiles_without_journaling_credential handle.await.unwrap(); } +#[tokio::test] +async fn environment_routes_remain_environment_auth_without_journaling_credentials() { + let provider = Arc::new(TestAuthProvider { + calls: Mutex::new(Vec::new()), + fail: false, + first_lease_expired: false, + }); + let (data_dir, socket, handle, _tmp) = start_routed_daemon(provider.clone()).await; + let host = dummy_host(); + + forward_envelope( + &environment_routed_envelope("environment-session", "env-org", "SessionStart"), + &socket, + &host, + false, + ) + .await + .unwrap(); + flush_session("environment-session", &socket, 5000) + .await + .unwrap(); + + let calls = provider.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0.source, AuthSource::Environment); + assert_eq!(calls[0].0.profile, None); + assert_eq!(calls[0].0.org_name.as_deref(), Some("env-org")); + + let journal = + std::fs::read_to_string(data_dir.join("journal").join("environment-session.ndjson")) + .unwrap(); + assert!(journal.contains(r#""source":"environment""#)); + assert!(!journal.contains("secret-environment")); + + shutdown(&socket).await; + handle.await.unwrap(); +} + #[tokio::test] async fn expiring_profile_lease_is_refreshed_for_the_pinned_profile() { let provider = Arc::new(TestAuthProvider { @@ -685,7 +744,7 @@ async fn auth_resolution_failure_is_reported_without_exposing_credentials() { ) .await .unwrap_err(); - assert!(error.to_string().contains("bt auth login")); + assert!(error.to_string().contains("bt login")); let status = run_status(StatusArgs { socket: Some(socket.clone()), session_id: Some("login-needed".into()), @@ -1299,6 +1358,10 @@ esac let _agent = EnvVarGuard::set("CODEX_BIN", &agent); let _daemon = EnvVarGuard::set("BT_DAEMON_TEST_BIN", env!("CARGO_BIN_EXE_bt-daemon")); let route = || SessionRoute { + auth: AuthSelection { + source: AuthSource::Environment, + ..AuthSelection::default() + }, destination: Some(bt_daemon::wire::TraceDestination::ProjectLogs { project_id: None, project_name: Some("managed-run-test".into()), From d641fbecb64adb0ae49fdbce39078267204c3e81 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 27 Aug 2026 00:34:05 +0800 Subject: [PATCH 7/7] Require installed OpenCode plugin in integration tests --- bt-daemon/tests/support/agents/opencode.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bt-daemon/tests/support/agents/opencode.rs b/bt-daemon/tests/support/agents/opencode.rs index 42b5c8a..1e8d534 100644 --- a/bt-daemon/tests/support/agents/opencode.rs +++ b/bt-daemon/tests/support/agents/opencode.rs @@ -58,12 +58,7 @@ impl OpenCodeAgent { } let plugin = std::env::var_os("OPENCODE_PLUGIN") .map(PathBuf::from) - .unwrap_or_else(|| { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("repository root") - .join("dist/opencode/dist/index.mjs") - }); + .expect("install the packed OpenCode plugin with its peer dependencies and set OPENCODE_PLUGIN to its dist/index.mjs entrypoint before running integration tests"); assert!( plugin.is_file(), "install the packed OpenCode plugin and set OPENCODE_PLUGIN before running integration tests: {}",