mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
360 lines
14 KiB
Rust
360 lines
14 KiB
Rust
//! Build-time agent env passthrough.
|
|
//!
|
|
//! Internal builds (buzz-releases) bake arbitrary `KEY=VALUE` pairs into the
|
|
//! binary via `BUZZ_BUILD_AGENT_ENV` (base64-encoded, newline-delimited).
|
|
//! OSS builds leave the compile-time var unset — nothing is injected.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use base64::Engine as _;
|
|
|
|
/// Return the baked-in build-time env pairs as a map.
|
|
///
|
|
/// Internal builds (buzz-releases) bake provider/model defaults and arbitrary
|
|
/// `KEY=VALUE` pairs into the binary at compile time. This function returns
|
|
/// those pairs as an owned map so callers can fold them into an in-process env
|
|
/// at the **lowest** precedence layer — user/persona values layered on top
|
|
/// override these baked defaults (last-write-wins, matching the existing
|
|
/// subprocess ordering where user env is written last).
|
|
///
|
|
/// OSS builds leave all `option_env!` vars unset, so this returns an empty
|
|
/// map — a safe no-op.
|
|
pub(crate) fn baked_build_env() -> BTreeMap<String, String> {
|
|
build_env_map(
|
|
option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER"),
|
|
option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL"),
|
|
option_env!("BUZZ_DESKTOP_BUILD_AGENT_ENV"),
|
|
)
|
|
}
|
|
|
|
/// Assemble a build-time env map from optional raw bake-in values.
|
|
///
|
|
/// Separated from `baked_build_env` so the assembly logic can be exercised in
|
|
/// unit tests without relying on compile-time `option_env!` values being set.
|
|
fn build_env_map(
|
|
raw_provider: Option<&str>,
|
|
raw_model: Option<&str>,
|
|
raw_agent_env: Option<&str>,
|
|
) -> BTreeMap<String, String> {
|
|
let mut map = BTreeMap::new();
|
|
if let Some(provider) = raw_provider {
|
|
if !provider.is_empty() {
|
|
map.insert("BUZZ_AGENT_PROVIDER".to_string(), provider.to_string());
|
|
}
|
|
}
|
|
if let Some(model) = raw_model {
|
|
if !model.is_empty() {
|
|
map.insert("BUZZ_AGENT_MODEL".to_string(), model.to_string());
|
|
}
|
|
}
|
|
if let Some(raw) = raw_agent_env {
|
|
// The value was base64-encoded at build time so the single-line Cargo
|
|
// output carries all KEY=VALUE pairs without truncation.
|
|
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw.as_bytes()) {
|
|
if let Ok(text) = std::str::from_utf8(&decoded) {
|
|
for (key, value) in parse_agent_env_lines(text) {
|
|
map.insert(key.to_string(), value.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Fold the baked build env under `merged_env` so user/persona values win.
|
|
///
|
|
/// Returns a new map with baked pairs as the floor and `merged_env` on top.
|
|
/// OSS builds return `merged_env` unchanged (empty baked map → no-op).
|
|
pub(crate) fn discovery_env_with_baked_floor(
|
|
merged_env: std::collections::BTreeMap<String, String>,
|
|
) -> std::collections::BTreeMap<String, String> {
|
|
let mut env = baked_build_env();
|
|
env.extend(merged_env);
|
|
env
|
|
}
|
|
|
|
/// Inject baked-in provider/model defaults and generic env pairs onto `cmd`.
|
|
///
|
|
/// Call this BEFORE writing record/persona metadata env vars so that the
|
|
/// record's explicit choices (written after) override the baked defaults.
|
|
/// User-supplied `record.env_vars` (written last) always win.
|
|
pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command) {
|
|
for (key, value) in baked_build_env() {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
|
|
/// Parse newline-delimited `KEY=VALUE` lines from a baked env blob.
|
|
/// Blank lines are skipped. Each non-blank line must contain `=`; the key
|
|
/// is everything before the first `=`, the value is everything after (values
|
|
/// may themselves contain `=`). Lines with an empty key are skipped.
|
|
pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> {
|
|
raw.lines()
|
|
.filter_map(|line| {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
return None;
|
|
}
|
|
let eq = line.find('=')?;
|
|
let key = &line[..eq];
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
Some((key, &line[eq + 1..]))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
baked_build_env, build_buzz_agent_provider_defaults, build_env_map,
|
|
discovery_env_with_baked_floor, parse_agent_env_lines,
|
|
};
|
|
|
|
#[test]
|
|
fn buzz_agent_provider_defaults_empty_in_oss_build() {
|
|
// OSS (and normal test) builds set neither BUZZ_BUILD_BUZZ_AGENT_*,
|
|
// so nothing is baked in and no BUZZ_AGENT_* is injected on spawn.
|
|
let mut cmd = std::process::Command::new("env");
|
|
cmd.env_clear();
|
|
build_buzz_agent_provider_defaults(&mut cmd);
|
|
let output = cmd.output().expect("env should run");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("BUZZ_AGENT_PROVIDER="),
|
|
"BUZZ_AGENT_PROVIDER should not be injected in OSS builds"
|
|
);
|
|
assert!(
|
|
!stdout.contains("BUZZ_AGENT_MODEL="),
|
|
"BUZZ_AGENT_MODEL should not be injected in OSS builds"
|
|
);
|
|
assert!(
|
|
!stdout.contains("DATABRICKS_HOST="),
|
|
"DATABRICKS_HOST should not be injected in OSS builds"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_splits_on_first_equals() {
|
|
// Value may itself contain `=` — only the first `=` is the separator.
|
|
let pairs = parse_agent_env_lines("DATABRICKS_HOST=https://host.example.com/path?a=1");
|
|
assert_eq!(
|
|
pairs,
|
|
vec![("DATABRICKS_HOST", "https://host.example.com/path?a=1")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_multiple_pairs() {
|
|
let raw = "KEY_A=value_a\nKEY_B=value_b";
|
|
let pairs = parse_agent_env_lines(raw);
|
|
assert_eq!(pairs, vec![("KEY_A", "value_a"), ("KEY_B", "value_b")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_skips_blank_lines() {
|
|
let raw = "KEY_A=val_a\n\n \nKEY_B=val_b";
|
|
let pairs = parse_agent_env_lines(raw);
|
|
assert_eq!(pairs, vec![("KEY_A", "val_a"), ("KEY_B", "val_b")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_skips_line_without_equals() {
|
|
// A malformed line (no `=`) is silently skipped — build.rs validates at
|
|
// compile time; runtime parsing is defensive.
|
|
let raw = "NO_EQUALS_HERE\nGOOD=value";
|
|
let pairs = parse_agent_env_lines(raw);
|
|
assert_eq!(pairs, vec![("GOOD", "value")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_skips_empty_key() {
|
|
// `=value` has an empty key — skip it.
|
|
let raw = "=orphan_value\nGOOD=value";
|
|
let pairs = parse_agent_env_lines(raw);
|
|
assert_eq!(pairs, vec![("GOOD", "value")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_empty_value_is_allowed() {
|
|
// `KEY=` is valid — empty value is intentional (clears an env var).
|
|
let pairs = parse_agent_env_lines("EMPTY=");
|
|
assert_eq!(pairs, vec![("EMPTY", "")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_agent_env_lines_empty_input_returns_empty() {
|
|
assert!(parse_agent_env_lines("").is_empty());
|
|
assert!(parse_agent_env_lines(" \n \n").is_empty());
|
|
}
|
|
|
|
// ── base64 round-trip regression ─────────────────────────────────────
|
|
//
|
|
// Cargo build-script output is line-oriented: a raw multiline value emitted
|
|
// via `cargo:rustc-env=KEY=...` would be truncated to the first line.
|
|
// build.rs base64-encodes the validated value; runtime.rs decodes it.
|
|
// This test verifies that a 2-pair value with a URL containing `=` survives
|
|
// the encode→decode→parse round-trip and both pairs land correctly.
|
|
#[test]
|
|
fn parse_agent_env_lines_base64_round_trip_preserves_all_pairs() {
|
|
use base64::Engine as _;
|
|
let raw =
|
|
"DATABRICKS_HOST=https://host.example.com/path?a=1&b=2\nDATABRICKS_MODEL=some-model";
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
|
let decoded_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded.as_bytes())
|
|
.expect("decode should succeed");
|
|
let decoded = std::str::from_utf8(&decoded_bytes).expect("utf8 should be valid");
|
|
let pairs = parse_agent_env_lines(decoded);
|
|
assert_eq!(pairs.len(), 2, "both pairs must survive the round-trip");
|
|
assert_eq!(
|
|
pairs[0],
|
|
("DATABRICKS_HOST", "https://host.example.com/path?a=1&b=2")
|
|
);
|
|
assert_eq!(pairs[1], ("DATABRICKS_MODEL", "some-model"));
|
|
}
|
|
|
|
// ── baked defaults ordering regression ───────────────────────────────
|
|
//
|
|
// `build_buzz_agent_provider_defaults` must run BEFORE
|
|
// `runtime_metadata_env_vars` writes the record's provider/model so that
|
|
// record values win (last-write-wins). This test simulates the ordering by
|
|
// writing the baked default first, then overwriting with the record value.
|
|
#[test]
|
|
fn baked_defaults_do_not_override_record_provider_written_after() {
|
|
let mut cmd = std::process::Command::new("env");
|
|
cmd.env_clear();
|
|
// Simulate what an internal build's baked defaults would inject.
|
|
cmd.env("BUZZ_AGENT_PROVIDER", "databricks");
|
|
// Simulate what runtime_metadata_env_vars writes from the record (comes after).
|
|
cmd.env("BUZZ_AGENT_PROVIDER", "anthropic");
|
|
let output = cmd.output().expect("env should run");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("BUZZ_AGENT_PROVIDER=anthropic"),
|
|
"record provider must win over baked default (last-write-wins)"
|
|
);
|
|
assert!(
|
|
!stdout.contains("BUZZ_AGENT_PROVIDER=databricks"),
|
|
"baked default must not survive when record provider is written after"
|
|
);
|
|
}
|
|
|
|
// ── baked_build_env / build_env_map tests ─────────────────────────────
|
|
|
|
#[test]
|
|
fn baked_build_env_is_empty_in_oss_build() {
|
|
// In OSS/test builds none of the BUZZ_DESKTOP_BUILD_* compile-time vars
|
|
// are set, so baked_build_env() must return an empty map — no
|
|
// accidental injection onto in-process discovery.
|
|
assert!(
|
|
baked_build_env().is_empty(),
|
|
"baked_build_env must be empty in OSS builds (no option_env! vars set)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_map_none_inputs_returns_empty() {
|
|
assert!(build_env_map(None, None, None).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_map_provider_and_model_are_mapped() {
|
|
let map = build_env_map(Some("databricks"), Some("my-model"), None);
|
|
assert_eq!(
|
|
map.get("BUZZ_AGENT_PROVIDER").map(String::as_str),
|
|
Some("databricks")
|
|
);
|
|
assert_eq!(
|
|
map.get("BUZZ_AGENT_MODEL").map(String::as_str),
|
|
Some("my-model")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_map_empty_provider_is_skipped() {
|
|
let map = build_env_map(Some(""), Some("my-model"), None);
|
|
assert!(
|
|
!map.contains_key("BUZZ_AGENT_PROVIDER"),
|
|
"empty provider must be skipped"
|
|
);
|
|
assert_eq!(
|
|
map.get("BUZZ_AGENT_MODEL").map(String::as_str),
|
|
Some("my-model")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_map_agent_env_blob_is_decoded_and_folded() {
|
|
use base64::Engine as _;
|
|
let raw = "DATABRICKS_HOST=https://block-lakehouse-production.cloud.databricks.com/\nDATABRICKS_MODEL=goose-claude-opus-4-8";
|
|
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
|
let map = build_env_map(None, None, Some(&blob));
|
|
assert_eq!(
|
|
map.get("DATABRICKS_HOST").map(String::as_str),
|
|
Some("https://block-lakehouse-production.cloud.databricks.com/")
|
|
);
|
|
assert_eq!(
|
|
map.get("DATABRICKS_MODEL").map(String::as_str),
|
|
Some("goose-claude-opus-4-8")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_map_user_env_overrides_baked_via_btreemap_extend() {
|
|
// Validates the precedence fold used in agent_models.rs:
|
|
// let mut discovery_env = baked_build_env(); // floor
|
|
// discovery_env.extend(merged_env); // user wins
|
|
use base64::Engine as _;
|
|
let raw = "DATABRICKS_HOST=https://baked.example.com/";
|
|
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
|
let mut discovery_env = build_env_map(None, None, Some(&blob));
|
|
|
|
// User-supplied env_vars override the baked value.
|
|
let mut user_env = std::collections::BTreeMap::new();
|
|
user_env.insert(
|
|
"DATABRICKS_HOST".to_string(),
|
|
"https://user.example.com/".to_string(),
|
|
);
|
|
discovery_env.extend(user_env);
|
|
|
|
assert_eq!(
|
|
discovery_env.get("DATABRICKS_HOST").map(String::as_str),
|
|
Some("https://user.example.com/"),
|
|
"user env_vars must override baked DATABRICKS_HOST"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_env_with_baked_floor_user_key_wins_over_baked() {
|
|
use base64::Engine as _;
|
|
let raw = "DATABRICKS_HOST=https://baked.example.com/";
|
|
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
|
// Simulate what an internal build would produce via build_env_map.
|
|
let _baked = build_env_map(None, None, Some(&blob));
|
|
|
|
// In OSS test builds baked_build_env() returns empty, so we exercise
|
|
// the helper's merge logic directly via merged_env carrying the key.
|
|
let mut merged = std::collections::BTreeMap::new();
|
|
merged.insert(
|
|
"DATABRICKS_HOST".to_string(),
|
|
"https://user.example.com/".to_string(),
|
|
);
|
|
merged.insert("OTHER_KEY".to_string(), "other".to_string());
|
|
|
|
let result = discovery_env_with_baked_floor(merged);
|
|
|
|
assert_eq!(
|
|
result.get("DATABRICKS_HOST").map(String::as_str),
|
|
Some("https://user.example.com/"),
|
|
"merged_env key must survive in discovery_env_with_baked_floor output"
|
|
);
|
|
assert_eq!(
|
|
result.get("OTHER_KEY").map(String::as_str),
|
|
Some("other"),
|
|
"unrelated merged_env keys must pass through unchanged"
|
|
);
|
|
}
|
|
}
|