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
29 changes: 16 additions & 13 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@ One self-contained Cargo crate, liftable to its own repo by copying
the `cli` feature for isolated testing/development. Env/flag static-token
auth only; not an end-user artifact.

## Dual consumption
## Dual consumption and authentication

The daemon core is credential-passive — it only ever *receives* a resolved
`BackendAuth` with each session's config — so two front-ends share all core
behavior. The `cli` feature only enables the standalone binary and its logging
subscriber:
Hooks send only a non-secret `SessionRoute`: an optional profile and
organization selection plus the trace destination. The long-lived daemon asks
its host's `AuthProvider` for a credential lease, pins the returned canonical
profile to that session, and refreshes expiring leases as needed. Independent
sessions can therefore use different `bt` profiles without exposing tokens to
hook processes or JavaScript plugins.

1. **Embedded in `bt`** (production): `bt` fills `BackendAuth` from its profile
/ OAuth / keychain auth.
2. **Standalone binary** (testing): fills it from `BRAINTRUST_API_KEY` etc.
1. **Embedded in `bt`** (production): the provider uses `bt`'s existing
profile, OAuth, refresh, keychain, organization, and backend URL machinery.
2. **Standalone binary** (testing): the provider uses `BRAINTRUST_API_KEY` and
related environment variables.

## Shared plugin settings

Expand All @@ -41,11 +44,11 @@ or use `config.json` under `BT_DAEMON_DATA_DIR` (by default
`~/.braintrust/state/bt-daemon/config.json` on Unix and
`%LOCALAPPDATA%\Braintrust\bt-daemon\config.json` on Windows).

See [`config.json.example`](config.json.example). Supported settings are
`traceToBraintrust`, `project`, `flushOnTurnEnd`, and
`additionalMetadata`. File values override plugin environment fallbacks.
Credentials, auth tokens, organization selection, and backend URLs are not
settings here; production resolves them through `bt`.
See [`config.json.example`](config.json.example). `traceToBraintrust` controls
enablement and `route` stores the selected profile, organization, typed
destination, flush mode, and metadata. Omitting `route.auth.profile` selects
the default `bt` profile. Credentials and backend URLs are never stored here;
production resolves and refreshes them through `bt`.

## Build / test

Expand Down
21 changes: 15 additions & 6 deletions bt-daemon/config.json.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
{
"_comment": "Shared by every coding-agent plugin connected to bt-daemon. Authentication and backend URLs are resolved by bt and do not belong here.",
"_comment": "Non-secret hook route. Authentication credentials and backend URLs are resolved by bt and do not belong here.",
"traceToBraintrust": true,
"project": "my-coding-agents",
"flushOnTurnEnd": false,
"additionalMetadata": {
"team": "platform",
"environment": "development"
"route": {
"auth": {
"profile": "work",
"org_name": "acme"
},
"destination": {
"type": "project_logs",
"project_name": "my-coding-agents"
},
"flush_mode": "fire_and_forget",
"additional_metadata": {
"team": "platform",
"environment": "development"
}
}
}
47 changes: 22 additions & 25 deletions bt-daemon/docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ Status: **frozen for the prototype.** This is the contract between plugin shims
(`hook` clients) and the daemon (`serve`), and between the embedded-in-`bt`
front-end and the standalone test binary.

All hook clients share one daemon-level, non-credential settings file. Its path
Hook clients may use a non-credential settings file. Its path
is `$BT_DAEMON_CONFIG`, falling back to `<BT_DAEMON_DATA_DIR>/config.json` and
then the platform default daemon state directory. The hook front-end applies
`traceToBraintrust`, `project`, `flushOnTurnEnd`, and `additionalMetadata`
before constructing `SessionConfig`. Authentication and backend URLs are
resolved by `bt` and never read from this file.
`traceToBraintrust` and the stored `route` before sending an event. Setup can
persist a project-scoped route; a managed run can point `BT_DAEMON_CONFIG` at
an invocation-local route file, or set `BT_TRACE_SESSION_ROUTE` to the selected
route JSON, while keeping generated hook commands stable.
The profile is optional and defaults through `bt`. Credentials and backend
URLs are resolved and refreshed inside the daemon and never enter this file.

`PROTOCOL_VERSION = 1`.

Expand Down Expand Up @@ -89,7 +92,7 @@ Result:
{
"protocol_version": 1,
"daemon_version": "0.1.0",
"capabilities": { "sources": ["codex", "claude-code", "debug"] }
"capabilities": { "sources": ["codex", "claude-code", "opencode", "debug"] }
}
```
If `protocol_version` is incompatible the daemon returns an application error;
Expand Down Expand Up @@ -159,21 +162,16 @@ Used for version handover and by tests.
"event": "PostToolUse",
"ts_ms": 1753639552123,
"payload": { "…raw agent-native hook payload…": true },
"config": {
"route": {
"auth": {
"token": "sk-…",
"api_url": "https://api.braintrust.dev",
"app_url": "https://www.braintrust.dev",
"profile": "work",
"org_name": "acme"
},
"destination": {
"type": "project_logs",
"project_id": "project-uuid",
"project_name": "codex"
},
"project": "codex",
"parent_span_id": null,
"root_span_id": null,
"flush_mode": "fire_and_forget",
"additional_metadata": { "…": "…" }
}
Expand All @@ -194,24 +192,23 @@ Field notes:
daemon.
- **`payload`** is opaque to transport and to everything except the translator
for `source`.
- **`config`** carries shim-resolved credentials and trace settings. The shim
attaches it on **every** event (stateless shim); the daemon keeps the latest
per session and only re-inits the Braintrust sink when it changes. `auth` is
filled by `bt`'s `resolve_auth` when embedded, or from env/flags in the
standalone binary. `flush_mode` ∈ `fire_and_forget` | `flush_on_turn_end`.
- **`route`** carries non-secret auth selection and trace settings. `profile`
is optional and resolves through `bt`'s default profile when absent;
`org_name` optionally constrains organization selection. The daemon resolves
the live credential, pins the returned canonical profile for the lifetime
of the session, and refreshes an expiring lease without changing that route.
A route cannot change after a session's first accepted event. `destination`
is required so setup/run must make project or parent selection explicit.
`flush_mode` ∈ `fire_and_forget` | `flush_on_turn_end`.
New front-ends set the typed `destination`: `project_logs` accepts a project
id and/or name, `experiment` accepts an experiment id, and `parent_span`
carries the complete exported `SpanComponents` object. The older `project`,
`parent_span_id`, `root_span_id`, and `_bt_experiment_id` fields remain
accepted when `destination` is absent.
carries the complete exported `SpanComponents` object.

### Redaction

`config.auth.token` (and any nested secret) is **never** written to the
journal or logs. The journal stores the envelope with `config.auth` reduced to
a non-secret fingerprint (`{ "api_url", "app_url", "org_name", "token_sha256_prefix" }`)
so replay can detect a credential change without persisting the secret; on
replay the live credentials must be re-supplied.
Live credentials returned by the host provider are **never** written to the
journal, logs, status, or RPC response. Envelopes journal only their non-secret
`route`, allowing restart recovery to resolve a fresh lease.

## Daemon lifecycle

Expand Down
21 changes: 21 additions & 0 deletions bt-daemon/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub struct Counters {

enum SessionMsg {
Event(Box<Envelope>),
Configure(Box<crate::wire::SessionConfig>, oneshot::Sender<()>),
Flush(oneshot::Sender<u64>),
Shutdown(oneshot::Sender<()>),
}
Expand Down Expand Up @@ -102,6 +103,18 @@ impl Session {
}
}

/// Reconfigure the sink before a refresh-triggered flush. Queue ordering
/// guarantees that all earlier events are processed first.
pub async fn configure(&self, config: crate::wire::SessionConfig) -> anyhow::Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(SessionMsg::Configure(Box::new(config), reply_tx))
.map_err(|_| anyhow::anyhow!("session actor is gone"))?;
reply_rx
.await
.map_err(|_| anyhow::anyhow!("session actor dropped configuration reply"))
}

/// Drain, flush, and stop the actor (used on daemon shutdown).
pub async fn shutdown(&self) {
let (reply_tx, reply_rx) = oneshot::channel();
Expand Down Expand Up @@ -171,6 +184,8 @@ impl SessionActor {
while let Some(msg) = rx.recv().await {
if let SessionMsg::Event(_) = msg {
self.counters.queued.fetch_sub(1, Ordering::Relaxed);
} else if let SessionMsg::Configure(_, r) = msg {
let _ = r.send(());
} else if let SessionMsg::Flush(r) = msg {
let _ = r.send(0);
} else if let SessionMsg::Shutdown(r) = msg {
Expand Down Expand Up @@ -229,6 +244,12 @@ impl SessionActor {
}
self.counters.queued.fetch_sub(1, Ordering::Relaxed);
}
SessionMsg::Configure(config, reply) => {
sink.configure(&config);
ctx.config = Some(*config);
self.refresh_permalink(sink.as_ref());
let _ = reply.send(());
}
SessionMsg::Flush(reply) => {
self.drain_flush(&mut translator, &mut sink, &ctx).await;
let _ = reply.send(self.counters.queued.load(Ordering::Relaxed));
Expand Down
41 changes: 13 additions & 28 deletions bt-daemon/src/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
//! Format: one [`RedactedEnvelope`] JSON value per line in
//! `<data_dir>/journal/<session_id>.ndjson`.

use crate::wire::{
AuthFingerprint, BackendAuth, Envelope, RedactedConfig, RedactedEnvelope, SessionConfig,
};
use crate::wire::{BackendAuth, Envelope, RedactedEnvelope};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;

Expand Down Expand Up @@ -109,37 +107,24 @@ pub async fn gc_old_journals(data_dir: &Path, max_age: std::time::Duration) {
/// rebuilding translator state; the sink must be re-supplied live credentials
/// if replay needs to actually deliver.
pub fn envelope_from_redacted(r: RedactedEnvelope) -> Envelope {
let route = r.route;
let config = route.as_ref().map(|route| {
route.with_auth(BackendAuth {
token: String::new(),
api_url: None,
app_url: None,
org_name: route.auth.org_name.clone(),
org_id: None,
})
});
Envelope {
source: r.source,
source_version: r.source_version,
session_id: r.session_id,
event: r.event,
ts_ms: r.ts_ms,
payload: r.payload,
config: r.config.map(config_from_redacted),
}
}

fn config_from_redacted(c: RedactedConfig) -> SessionConfig {
let AuthFingerprint {
api_url,
app_url,
org_name,
..
} = c.auth;
SessionConfig {
auth: BackendAuth {
token: String::new(),
api_url,
app_url,
org_name,
org_id: None,
},
destination: c.destination,
project: c.project,
parent_span_id: c.parent_span_id,
root_span_id: c.root_span_id,
flush_mode: c.flush_mode,
additional_metadata: c.additional_metadata,
route,
config,
}
}
Loading
Loading