diff --git a/.github/workflows/build2-baml-wrapper.reusable.yaml b/.github/workflows/build2-baml-wrapper.reusable.yaml index 4c562e5c77a..9fcd7904084 100644 --- a/.github/workflows/build2-baml-wrapper.reusable.yaml +++ b/.github/workflows/build2-baml-wrapper.reusable.yaml @@ -10,7 +10,7 @@ on: required: true secrets: BAML_WRAPPER_BOUNDARY_API_KEY: - description: BCS credential with permission to mint public ingestion tokens + description: BCS telemetry API key to embed in the packed wrapper required: true permissions: contents: read @@ -55,14 +55,12 @@ jobs: - name: Pack candidates for every wrapper platform and shipped variant env: WRAPPER_VERSION: ${{ inputs.wrapper_version }} - BOUNDARY_PROJECT: ${{ vars.BAML_WRAPPER_BCS_PROJECT }} - BAML_WRAPPER_BCS_ENVIRONMENT: ${{ vars.BAML_WRAPPER_BCS_ENVIRONMENT }} - BOUNDARY_API_KEY: ${{ secrets.BAML_WRAPPER_BOUNDARY_API_KEY }} + BAML_WRAPPER_BOUNDARY_API_KEY: ${{ secrets.BAML_WRAPPER_BOUNDARY_API_KEY }} run: | set -euo pipefail python - <<'PY' import json, os, pathlib, subprocess, tarfile, zipfile - for setting in ('BOUNDARY_PROJECT', 'BAML_WRAPPER_BCS_ENVIRONMENT', 'BOUNDARY_API_KEY'): + for setting in ('BAML_WRAPPER_BOUNDARY_API_KEY',): if not os.environ.get(setting, '').strip(): raise SystemExit(f'Missing wrapper BCS release configuration: {setting}') repo = pathlib.Path.cwd() @@ -78,7 +76,7 @@ jobs: staging = out / variant binary = staging / 'bin' / f'baml{suffix}' binary.parent.mkdir(parents=True) - command = ['python3', 'baml_language/wrapper/build', '--cli', str(cli), '--pack-host', str(host), '--target', target, '--version', os.environ['WRAPPER_VERSION'], '--output', str(binary), '--embed-telemetry', '--telemetry-environment', os.environ['BAML_WRAPPER_BCS_ENVIRONMENT']] + command = ['python3', 'baml_language/wrapper/build', '--cli', str(cli), '--pack-host', str(host), '--target', target, '--version', os.environ['WRAPPER_VERSION'], '--output', str(binary), '--embed-telemetry', 'BAML_WRAPPER_BOUNDARY_API_KEY'] if disabled: command.append('--no-self-update') subprocess.run(command, check=True) diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 4c5af8dac19..a72151810f1 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1885,7 +1885,6 @@ dependencies = [ "baml_env", "borsh", "serde", - "sha2", "strsim", "thiserror 2.0.18", "toml 0.8.23", diff --git a/baml_language/crates/baml_artifact/src/lib.rs b/baml_language/crates/baml_artifact/src/lib.rs index 07fcfd6f14c..6d25877e480 100644 --- a/baml_language/crates/baml_artifact/src/lib.rs +++ b/baml_language/crates/baml_artifact/src/lib.rs @@ -127,7 +127,10 @@ pub const MAGIC: &[u8; 8] = b"BAMLART\0"; /// /// Version 27: appended `ConcatStrings` to the instruction/opcode sets, which /// joins a chain of string `+` in one step. -pub const FORMAT_VERSION: u32 = 27; +/// +/// Version 28 replaces the embedded telemetry build-token registration with the +/// publisher's API key in the packed dispatch envelope. +pub const FORMAT_VERSION: u32 = 28; /// Git commit this crate was built from (`BAML_GIT_SHA`, else the checkout's /// HEAD), or empty when neither was available. diff --git a/baml_language/crates/baml_cli/src/embed_telemetry.rs b/baml_language/crates/baml_cli/src/embed_telemetry.rs index 85a77a2c351..e0c63b4626e 100644 --- a/baml_language/crates/baml_cli/src/embed_telemetry.rs +++ b/baml_language/crates/baml_cli/src/embed_telemetry.rs @@ -1,171 +1,140 @@ -//! Build-time telemetry provisioning shared by pack and generate. Private minting -//! credentials remain in the build process; artifacts receive only public ingestion. -use anyhow::{Context, Result, bail}; -use btel_settings::artifact::{ - ArtifactTelemetry, BytecodeDigest, EmbeddedIngestion, IngestionDestination, PublicCredential, -}; +//! Build-time API-key embedding shared by pack and generate. +use std::ffi::OsString; + +use anyhow::{Context, Result}; +use btel_settings::artifact::{ArtifactTelemetry, EmbeddedCredential, EmbeddedIngestion}; use clap::Args; #[derive(Args, Clone, Debug, Default)] pub struct EmbedTelemetryArgs { - /// Mint and embed a public ingestion credential for this build. - #[arg(long, help_heading = "Telemetry options")] - pub embed_telemetry: bool, - /// Existing environment to bind the embedded credential to. - #[arg( - long, - value_name = "NAME", - requires = "embed_telemetry", - help_heading = "Telemetry options" - )] - pub telemetry_environment: Option, + /// Embed the value of this environment variable as the telemetry API key. + #[arg(long, value_name = "ENV_VAR", value_parser = parse_envvar, help_heading = "Telemetry options")] + pub embed_telemetry: Option, } -pub struct Provisioning { - client: bcs_api::Client, - authorization: bcs_api::credentials::RequestAuthorization, - project: String, - environment: String, - context: bcs_api::diagnostics::Context, +fn parse_envvar(name: &str) -> Result { + let mut bytes = name.bytes(); + if matches!(bytes.next(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'_')) + && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + Ok(name.to_owned()) + } else { + Err( + "expected an environment-variable name, for example BAML_WRAPPER_BOUNDARY_API_KEY" + .into(), + ) + } } impl EmbedTelemetryArgs { - pub fn prepare( - &self, - policy: &mut ArtifactTelemetry, - default_environment: Option<&str>, - producer: &str, - ) -> Result> { - if !self.embed_telemetry { - return Ok(None); - } - let environment = self.telemetry_environment.as_deref().or(default_environment) - .filter(|value| !value.is_empty()) - .with_context(|| format!("Embedding telemetry requires an existing environment. Pass `--telemetry-environment=` or set `{producer}.telemetry_environment` in baml.toml."))?.to_owned(); - let project = baml_env::string_var("BOUNDARY_PROJECT")?.or_else(|| policy.project.clone()) - .filter(|value| !value.is_empty()) - .context("Embedding telemetry requires a project. Set `[boundary].project = \"org/project\"` in baml.toml or set BOUNDARY_PROJECT=org/project.")?; - if project.split('/').count() != 2 || project.split('/').any(str::is_empty) { - bail!( - "BOUNDARY_PROJECT or boundary.project must have the form org_handle/project_name." - ); + pub fn apply(&self, policy: &mut ArtifactTelemetry) -> Result<()> { + self.apply_with(policy, baml_env::os_var)?; + if self.embed_telemetry.is_some() { + // Keep the selected build endpoint as the artifact's runtime default. + let endpoint = bcs_api::Endpoint::with_default(policy.api_url.as_deref())?; + policy.api_url = Some(endpoint.as_str().to_owned()); } - let endpoint = bcs_api::Endpoint::with_default(policy.api_url.as_deref())?; - let key = baml_env::string_var("BOUNDARY_API_KEY")?; - let source = if key.is_some() { - bcs_api::diagnostics::CredentialSource::ApiKey - } else { - bcs_api::diagnostics::CredentialSource::SavedLogin - }; - let context = bcs_api::diagnostics::Context { - endpoint: Some(endpoint.clone()), - source, - operation: bcs_api::diagnostics::Operation::MintBuildToken, - }; - let credential = match key { - Some(key) => { - if key == bcs_api::credentials::LOCAL_API_KEY - || key.starts_with(bcs_api::credentials::PUBLIC_PREFIX) - { - bail!( - "Embedding telemetry requires a Boundary API key with mint permission, or an administrator's `baml auth login` session. BOUNDARY_API_KEY=local and public ingestion credentials cannot mint tokens." - ); - } - bcs_api::Secret::new(key) - } - None => bcs_api::Store::new(&endpoint) - .and_then(|store| store.read()) - .and_then(|saved| { - saved - .map(|saved| saved.refresh_token) - .ok_or(bcs_api::Error::MissingCredentials) - }) - .map_err(|error| { - context.report(error, bcs_api::diagnostics::Outcome::ArtifactBuildFailed) - })?, - }; - // The chosen build endpoint and project are defaults for the shipped artifact. - policy.project = Some(project.clone()); - policy.api_url = Some(endpoint.as_str().to_owned()); - let authorization = bcs_api::credentials::RequestAuthorization { - authentication: bcs_api::credentials::Authentication::shared( - endpoint.as_str(), - credential, - ), - target: None, - }; - Ok(Some(Provisioning { - client: bcs_api::Client::new(endpoint)?, - authorization, - project, - environment, - context, - })) + Ok(()) } -} -impl Provisioning { - pub fn mint(&self, digest: BytecodeDigest) -> Result { - let wire_digest = bcs_api::builds::BytecodeDigest { - version: digest.version, - algorithm: digest.algorithm.clone(), - value: digest.value.clone(), - }; - let request = bcs_api::builds::MintBuildToken { - target: bcs_api::builds::BuildTarget::Handle { - project: &self.project, - environment: &self.environment, - }, - bytecode_digest: &wire_digest, + fn apply_with( + &self, + policy: &mut ArtifactTelemetry, + mut read: impl FnMut(&str) -> Option, + ) -> Result<()> { + let Some(name) = &self.embed_telemetry else { + return Ok(()); }; - let minted = self - .client - .mint_build_token(&self.authorization, uuid::Uuid::new_v4(), &request) - .map_err(|error| { - self.context - .report(error, bcs_api::diagnostics::Outcome::ArtifactBuildFailed) + let value = read(name) + .with_context(|| { + format!("Cannot embed telemetry: environment variable {name} is not set.") + })? + .into_string() + .map_err(|_| { + anyhow::anyhow!( + "Cannot embed telemetry: environment variable {name} is not valid Unicode." + ) })?; - Ok(EmbeddedIngestion { - token: PublicCredential::new(minted.token.expose().to_owned()), - token_id: minted.token_id, - build_id: minted.build_id, - product_id: minted.product_id, - destination: IngestionDestination { - org_id: minted.destination.org_id, - project_id: minted.destination.project_id, - environment_id: minted.destination.environment_id, - }, - bytecode_digest: digest, - }) + anyhow::ensure!( + !value.trim().is_empty(), + "Cannot embed telemetry: environment variable {name} is empty." + ); + policy.embedded = Some(Box::new(EmbeddedIngestion { + api_key: EmbeddedCredential::new(value), + })); + Ok(()) } } #[cfg(test)] mod tests { use super::*; + #[test] - fn environment_default_does_not_enable_minting_and_missing_environment_explains_both_inputs() { - assert!( - EmbedTelemetryArgs::default() - .prepare(&mut ArtifactTelemetry::default(), Some("staging"), "pack") - .unwrap() - .is_none() - ); - for producer in ["pack", "bridge"] { - let args = EmbedTelemetryArgs { - embed_telemetry: true, - telemetry_environment: None, - }; + fn omission_never_reads_the_environment() { + EmbedTelemetryArgs::default() + .apply_with(&mut ArtifactTelemetry::default(), |_| { + panic!("unexpected read") + }) + .unwrap(); + } + + #[test] + fn embeds_only_the_named_value_verbatim_and_redacts_debug() { + let args = EmbedTelemetryArgs { + embed_telemetry: Some("BAML_WRAPPER_BOUNDARY_API_KEY".into()), + }; + let mut policy = ArtifactTelemetry::default(); + args.apply_with(&mut policy, |name| { + assert_eq!(name, "BAML_WRAPPER_BOUNDARY_API_KEY"); + Some(" bdry_secret_embedded ".into()) + }) + .unwrap(); + let key = &policy.embedded.unwrap().api_key; + assert_eq!(key.expose(), " bdry_secret_embedded "); + assert_eq!(format!("{key:?}"), "[REDACTED]"); + } + + #[test] + fn missing_empty_and_non_unicode_values_fail_without_exposing_the_value() { + let args = EmbedTelemetryArgs { + embed_telemetry: Some("KEY".into()), + }; + for (value, expected) in [ + (None, "not set"), + (Some("".into()), "empty"), + (Some(" ".into()), "empty"), + ] { let error = args - .prepare(&mut ArtifactTelemetry::default(), None, producer) - .err() - .unwrap(); + .apply_with(&mut ArtifactTelemetry::default(), |_| value.clone()) + .unwrap_err(); assert_eq!( error.to_string(), - format!( - r#"Embedding telemetry requires an existing environment. Pass `--telemetry-environment=` or set `{producer}.telemetry_environment` in baml.toml."# - ) + format!("Cannot embed telemetry: environment variable KEY is {expected}.") + ); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let error = args + .apply_with(&mut ArtifactTelemetry::default(), |_| { + Some(OsString::from_vec(vec![0xff])) + }) + .unwrap_err(); + assert_eq!( + error.to_string(), + "Cannot embed telemetry: environment variable KEY is not valid Unicode." ); } } + + #[test] + fn requires_a_variable_name() { + for name in ["KEY", "_KEY_2", "BAML_WRAPPER_BOUNDARY_API_KEY"] { + assert!(parse_envvar(name).is_ok()); + } + for name in ["", "1KEY", "KEY=value", "not a name"] { + assert!(parse_envvar(name).is_err()); + } + } } diff --git a/baml_language/crates/baml_cli/src/generate.rs b/baml_language/crates/baml_cli/src/generate.rs index 8f888d24555..c3bcb95ac94 100644 --- a/baml_language/crates/baml_cli/src/generate.rs +++ b/baml_language/crates/baml_cli/src/generate.rs @@ -289,9 +289,9 @@ impl GenerateArgs { } pub fn run(&self) -> Result { - if self.command.is_some() && self.telemetry.embed_telemetry { + if self.command.is_some() && self.telemetry.embed_telemetry.is_some() { anyhow::bail!( - "`--embed-telemetry` applies to `baml generate`, which produces the SDK. Add the generator first, then run `baml generate --embed-telemetry`." + "`--embed-telemetry` applies to `baml generate`, which produces the SDK. Add the generator first, then run `baml generate --embed-telemetry `." ); } match &self.command { @@ -403,21 +403,9 @@ impl GenerateArgs { let mut embedded_baml_toml = build_embedded_baml_toml(&from)?; let mut manifest: toml::Value = toml::from_str(&embedded_baml_toml)?; - let default_environment = manifest - .get("bridge") - .and_then(|table| table.get("telemetry_environment")); - let default_environment = default_environment - .map(|value| { - value - .as_str() - .context("bridge.telemetry_environment must be a string.") - }) - .transpose()?; let mut telemetry: btel_settings::artifact::ArtifactTelemetry = manifest["__baml_codegen"]["telemetry"].clone().try_into()?; - let provisioning = self - .telemetry - .prepare(&mut telemetry, default_environment, "bridge")?; + self.telemetry.apply(&mut telemetry)?; // Tests were checked above, but generated clients never run them. // Exclude both test forms before building any persisted compiler data, @@ -436,10 +424,7 @@ impl GenerateArgs { .map_err(|e| anyhow!("compilation failed: {e}"))?; let baml_bytecode = baml_artifact::encode(baml_artifact::ArtifactKind::Program, &program) .map_err(|e| anyhow!("failed to serialize BAML bytecode: {e}"))?; - if let Some(provisioning) = provisioning { - reporter.spin("Provisioning", "embedded telemetry"); - let digest = btel_settings::artifact::build_digest(&baml_bytecode, &telemetry)?; - telemetry.embedded = Some(Box::new(provisioning.mint(digest)?)); + if self.telemetry.embed_telemetry.is_some() { manifest["__baml_codegen"]["telemetry"] = toml::Value::try_from(telemetry)?; // Replace only owned metadata; preserve the publisher's original manifest bytes. let original = std::fs::read_to_string(from.join("baml.toml"))?; diff --git a/baml_language/crates/baml_cli/src/pack_command.rs b/baml_language/crates/baml_cli/src/pack_command.rs index 9e661becbb2..632324bb380 100644 --- a/baml_language/crates/baml_cli/src/pack_command.rs +++ b/baml_language/crates/baml_cli/src/pack_command.rs @@ -204,19 +204,7 @@ impl PackArgs { &manifest, btel_settings::artifact::ArtifactKind::Pack, )?; - let default_environment = manifest - .get("pack") - .and_then(|table| table.get("telemetry_environment")); - let default_environment = default_environment - .map(|value| { - value - .as_str() - .context("pack.telemetry_environment must be a string.") - }) - .transpose()?; - let provisioning = self - .telemetry - .prepare(&mut telemetry, default_environment, "pack")?; + self.telemetry.apply(&mut telemetry)?; let (db, root, program, needs_format_hint) = self.load_and_compile(reporter)?; // Mirror `baml run`'s format advisory: if any source file @@ -241,7 +229,7 @@ impl PackArgs { validate_help_param(&engine, &t.qualified_name)?; } - let mut envelope = PackEnvelope { + let envelope = PackEnvelope { program, mode: mode.clone(), targets: targets @@ -255,11 +243,6 @@ impl PackArgs { output_format: self.output_format, telemetry, }; - if let Some(provisioning) = provisioning { - reporter.spin("Provisioning", "embedded telemetry"); - envelope.telemetry.embedded = - Some(Box::new(provisioning.mint(envelope.telemetry_digest()?)?)); - } let (serialized, fingerprint) = baml_artifact::encode_with_digest( baml_artifact::ArtifactKind::PackedProgram, &envelope, diff --git a/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs b/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs index b1a0343ad15..ad6a17fd3c6 100644 --- a/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs +++ b/baml_language/crates/baml_cli/tests/embedded_telemetry_e2e.rs @@ -1,20 +1,16 @@ -//! Real pack/generate commands against the proposed public credential API. +//! Pack/generate embed the named API key without build-time cloud requests. #[path = "../../btel_bcs/tests/support/mod.rs"] mod cloud_protocol; mod common; use std::{ path::Path, process::{Command, Output}, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, }; use serde_json::{Value, json}; use wiremock::{ Mock, MockServer, Request, ResponseTemplate, - matchers::{header, method, path, path_regex}, + matchers::{method, path_regex}, }; fn cli(project: &Path, api: &str, args: &[&str]) -> Output { @@ -23,7 +19,8 @@ fn cli(project: &Path, api: &str, args: &[&str]) -> Output { .args(args) .current_dir(project) .env("BOUNDARY_API_URL", api) - .env("BOUNDARY_API_KEY", "bdry_secret_builder_private") + .env("BOUNDARY_API_KEY", "bdry_secret_builder_do_not_embed") + .env("BAML_WRAPPER_BOUNDARY_API_KEY", "bdry_secret_embedded") .env_remove("BOUNDARY_PROJECT") .env("BAML_HOME", common::shared_baml_home()) .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") @@ -38,21 +35,6 @@ fn succeeded(output: &Output) { String::from_utf8_lossy(&output.stderr) ); } -async fn mint_api(server: &MockServer) { - let count = Arc::new(AtomicUsize::new(0)); - Mock::given(method("POST")).and(path("/v1/build-tokens")) - .and(header("Authorization", "Bearer bdry_secret_builder_private")) - .respond_with(move |request: &Request| { - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["target"], json!({"kind":"handle","project":"publisher/app","environment":"staging"})); - assert_eq!(body["bytecodeDigest"]["version"], 1); - assert_eq!(body["bytecodeDigest"]["algorithm"], "sha256"); - let number = count.fetch_add(1, Ordering::SeqCst); - ResponseTemplate::new(201).set_body_json(json!({"buildId":"build-1", "tokenId":format!("token-{number}"), - "token": format!("bdry_public_{number:064x}"), "productId":"product-1", - "destination":{"orgId":"o","projectId":"p","environmentId":"e"}, "bytecodeDigest":body["bytecodeDigest"]})) - }).mount(server).await; -} async fn ingest_api(server: &MockServer) { let base = server.uri(); Mock::given(method("POST")) @@ -78,11 +60,8 @@ fn manifest(overrides: &str) -> String { r#"[package] name = "embedded-test" [boundary] -project = "publisher/app" [pack] -telemetry_environment = "staging" [bridge] -telemetry_environment = "staging" [generator.rust] output_type = "rust" output_dir = "generated" @@ -93,34 +72,42 @@ naming_convention = "preserve-case" } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn packed_public_credentials_are_fresh_and_fixed_with_endpoint_override() { +async fn packed_api_key_is_embedded_offline_and_fixed_with_endpoint_override() { common::ensure_built(); let temp = tempfile::tempdir().unwrap(); common::write_project(temp.path(), "function main() -> int { 7 }\n"); std::fs::write(temp.path().join("baml.toml"), manifest("")).unwrap(); let build_server = MockServer::start().await; - mint_api(&build_server).await; - for _ in 0..2 { - succeeded(&cli( - temp.path(), - &build_server.uri(), - &["pack", "main", "--embed-telemetry", "-o", "out"], - )); - } - let mints = build_server.received_requests().await.unwrap(); - assert_eq!(mints.len(), 2); - assert_ne!( - mints[0].headers["idempotency-key"], - mints[1].headers["idempotency-key"] + succeeded(&cli( + temp.path(), + &build_server.uri(), + &[ + "pack", + "main", + "--embed-telemetry", + "BAML_WRAPPER_BOUNDARY_API_KEY", + "-o", + "out", + ], + )); + assert!(build_server.received_requests().await.unwrap().is_empty()); + let binary = std::fs::read(temp.path().join("out")).unwrap(); + assert!( + binary + .windows(b"bdry_secret_embedded".len()) + .any(|bytes| bytes == b"bdry_secret_embedded") + ); + assert!( + !binary + .windows(b"bdry_secret_builder_do_not_embed".len()) + .any(|bytes| bytes == b"bdry_secret_builder_do_not_embed") ); - let first: Value = serde_json::from_slice(&mints[0].body).unwrap(); - let second: Value = serde_json::from_slice(&mints[1].body).unwrap(); - assert_eq!(first["bytecodeDigest"], second["bytecodeDigest"]); let run_server = MockServer::start().await; ingest_api(&run_server).await; let run = || { Command::new(temp.path().join("out")) .env("BOUNDARY_API_URL", run_server.uri()) + .env_remove("BAML_WRAPPER_BOUNDARY_API_KEY") .env("BOUNDARY_API_KEY", "bdry_secret_customer_do_not_use") .env("BOUNDARY_PROJECT", "customer/other-project") .env("BAML_TELEMETRY", "off") @@ -137,16 +124,13 @@ async fn packed_public_credentials_are_fresh_and_fixed_with_endpoint_override() for request in requests.iter().filter(|request| request.method == "POST") { assert_eq!( request.headers["authorization"], - format!("Bearer bdry_public_{:064x}", 1) + "Bearer bdry_secret_embedded" ); let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["buildId"], "build-1"); - assert_eq!( - body["target"], - json!({"orgId":"o","projectId":"p","environmentId":"e"}) - ); + assert_eq!(body.get("buildId"), None); + assert_eq!(body["target"], json!({})); } - assert_eq!(build_server.received_requests().await.unwrap().len(), 2); + assert!(build_server.received_requests().await.unwrap().is_empty()); run_server.reset().await; Mock::given(method("POST")) @@ -177,12 +161,18 @@ BAML_TELEMETRY = "ACME_TELEMETRY" ) .unwrap(); let server = MockServer::start().await; - mint_api(&server).await; ingest_api(&server).await; succeeded(&cli( temp.path(), &server.uri(), - &["pack", "main", "--embed-telemetry", "-o", "out"], + &[ + "pack", + "main", + "--embed-telemetry", + "BAML_WRAPPER_BOUNDARY_API_KEY", + "-o", + "out", + ], )); let output = Command::new(temp.path().join("out")) .env("BOUNDARY_API_URL", server.uri()) @@ -199,7 +189,7 @@ BAML_TELEMETRY = "ACME_TELEMETRY" .await .unwrap() .iter() - .filter(|request| request.method == "POST" && request.url.path() != "/v1/build-tokens") + .filter(|request| request.method == "POST") { assert_eq!( request.headers["authorization"], @@ -225,19 +215,18 @@ BAML_TELEMETRY = "ACME_TELEMETRY" } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn generate_embeds_registration_matching_bytecode_and_plain_generate_never_mints() { +async fn generate_embeds_named_api_key_and_plain_generate_omits_it() { let temp = tempfile::tempdir().unwrap(); common::write_project(temp.path(), "function main() -> int { 7 }\n"); std::fs::write(temp.path().join("baml.toml"), manifest("")).unwrap(); let server = MockServer::start().await; - mint_api(&server).await; succeeded(&cli( temp.path(), &server.uri(), &[ "generate", "--embed-telemetry", - "--telemetry-environment=staging", + "BAML_WRAPPER_BOUNDARY_API_KEY", ], )); let inlined = @@ -257,17 +246,22 @@ async fn generate_embeds_registration_matching_bytecode_and_plain_generate_never .clone() .try_into() .unwrap(); - let program = - std::fs::read(temp.path().join("generated/baml_sdk/src/_inlinedbaml.bin")).unwrap(); - policy.verify_build(&program).unwrap(); - assert_eq!(policy.embedded.as_ref().unwrap().build_id, "build-1"); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(policy.api_url.as_deref(), Some(server.uri().as_str())); + assert_eq!( + policy.embedded.as_ref().unwrap().api_key.expose(), + "bdry_secret_embedded" + ); + assert!(server.received_requests().await.unwrap().is_empty()); succeeded(&cli(temp.path(), &server.uri(), &["generate"])); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + let inlined = + std::fs::read_to_string(temp.path().join("generated/baml_sdk/src/_inlinedbaml.rs")) + .unwrap(); + assert!(!inlined.contains("bdry_secret_embedded")); + assert!(server.received_requests().await.unwrap().is_empty()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact() { +async fn malformed_publisher_settings_fail_before_writing_an_artifact() { let temp = tempfile::tempdir().unwrap(); common::write_project(temp.path(), "function main() -> int { 7 }\n"); let server = MockServer::start().await; @@ -283,7 +277,6 @@ async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact Valid [pack] settings: on_initial_telemetry_failure initial_telemetry_warning_message - telemetry_environment http_bodies env_var_names"#, ), @@ -296,7 +289,6 @@ async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact Valid [bridge] settings: on_initial_telemetry_failure initial_telemetry_warning_message - telemetry_environment http_bodies env_var_names"#, ), @@ -325,12 +317,6 @@ async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact project api_url"#, ), - ( - "pack", - "telemetry_environment", - "false", - r#"error: pack.telemetry_environment must be a non-empty string."#, - ), ] { let mut config: toml::Value = manifest("").parse().unwrap(); let parsed: toml::Value = format!("[{table}]\n{key} = {value}").parse().unwrap(); @@ -355,14 +341,14 @@ async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact "pack", "main", "--embed-telemetry", - "--telemetry-environment=staging", + "BAML_WRAPPER_BOUNDARY_API_KEY", "-o", "out", ][..], &[ "generate", "--embed-telemetry", - "--telemetry-environment=staging", + "BAML_WRAPPER_BOUNDARY_API_KEY", ][..], ] { let output = cli(temp.path(), &server.uri(), args); @@ -379,13 +365,59 @@ async fn malformed_publisher_settings_fail_before_minting_or_writing_an_artifact } } +#[test] +fn embedding_requires_a_named_nonempty_variable_without_falling_back_to_other_credentials() { + let temp = tempfile::tempdir().unwrap(); + common::write_project(temp.path(), "function main() -> int { 7 }\n"); + std::fs::write(temp.path().join("baml.toml"), manifest("")).unwrap(); + for command in ["pack", "generate"] { + for value in [None, Some(""), Some(" ")] { + let mut cli = Command::new(common::baml_cli()); + cli.args(["--agent-skill-check", "off", command]); + if command == "pack" { + cli.args(["main", "-o", "out"]); + } + cli.args(["--embed-telemetry", "BAML_TEST_MISSING_EMBED_KEY"]) + .current_dir(temp.path()) + .env("BOUNDARY_API_KEY", "bdry_secret_do_not_fall_back") + .env_remove("BAML_TEST_MISSING_EMBED_KEY") + .env("BAML_HOME", common::shared_baml_home()) + .env("DEV_BAML_CLI_DISABLE_AGENT_DETECTION", "1") + .env("DO_NOT_TRACK", "1"); + if let Some(value) = value { + cli.env("BAML_TEST_MISSING_EMBED_KEY", value); + } + let output = cli.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(if value.is_none() { + "environment variable BAML_TEST_MISSING_EMBED_KEY is not set." + } else { + "environment variable BAML_TEST_MISSING_EMBED_KEY is empty." + }), + "{stderr}" + ); + assert!(!stderr.contains("bdry_secret_do_not_fall_back")); + assert!(!temp.path().join("out").exists()); + assert!(!temp.path().join("generated").exists()); + } + let output = Command::new(common::baml_cli()) + .args(["--agent-skill-check", "off", command, "--embed-telemetry"]) + .env("BAML_HOME", common::shared_baml_home()) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("a value is required")); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn wrapper_build_embeds_public_credentials_for_both_release_variants() { +async fn wrapper_build_embeds_named_api_key_for_both_release_variants() { let built = common::ensure_built(); let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); let temp = tempfile::tempdir().unwrap(); let server = MockServer::start().await; - mint_api(&server).await; let python = if cfg!(windows) { "python" } else { "python3" }; let build = |binary: &Path, disabled: bool, embed: bool| { let mut command = Command::new(python); @@ -399,7 +431,8 @@ async fn wrapper_build_embeds_public_credentials_for_both_release_variants() { .arg(binary) .env("BOUNDARY_API_URL", server.uri()) .env("BOUNDARY_PROJECT", "publisher/app") - .env("BOUNDARY_API_KEY", "bdry_secret_builder_private") + .env("BOUNDARY_API_KEY", "bdry_secret_builder_do_not_embed") + .env("BAML_WRAPPER_BOUNDARY_API_KEY", "bdry_secret_embedded") .env("BAML_HOME", common::shared_baml_home()) .env("BAML_TELEMETRY", "off") .env("DO_NOT_TRACK", "1") @@ -408,7 +441,7 @@ async fn wrapper_build_embeds_public_credentials_for_both_release_variants() { command.arg("--no-self-update"); } if embed { - command.args(["--embed-telemetry", "--telemetry-environment", "staging"]); + command.args(["--embed-telemetry", "BAML_WRAPPER_BOUNDARY_API_KEY"]); } command.output().unwrap() }; @@ -416,17 +449,13 @@ async fn wrapper_build_embeds_public_credentials_for_both_release_variants() { let binary = temp.path().join(format!("wrapper-{index}")); succeeded(&build(&binary, disabled, true)); let bytes = std::fs::read(&binary).unwrap(); - let public = format!("bdry_public_{index:064x}"); - assert!( - bytes - .windows(public.len()) - .any(|part| part == public.as_bytes()) - ); - let private = b"bdry_secret_builder_private"; + let embedded = b"bdry_secret_embedded"; + assert!(bytes.windows(embedded.len()).any(|part| part == embedded)); + let private = b"bdry_secret_builder_do_not_embed"; assert!(!bytes.windows(private.len()).any(|part| part == private)); let output = Command::new(&binary) .arg("--help") - .env("BAML_WRAPPER_BCS_TELEMETRY", "off") + .env("BAML_TELEMETRY", "off") .env("BOUNDARY_API_URL", server.uri()) .env("BOUNDARY_API_KEY", "bdry_secret_runtime_ignored") .env("DO_NOT_TRACK", "1") @@ -436,20 +465,33 @@ async fn wrapper_build_embeds_public_credentials_for_both_release_variants() { succeeded(&output); assert!(String::from_utf8_lossy(&output.stdout).contains("Usage: baml")); } - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert!(server.received_requests().await.unwrap().is_empty()); succeeded(&build(&temp.path().join("offline-wrapper"), false, false)); - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert!(server.received_requests().await.unwrap().is_empty()); - server.reset().await; - Mock::given(method("POST")) - .and(path("/v1/build-tokens")) - .respond_with(ResponseTemplate::new(403)) - .mount(&server) - .await; - let denied = temp.path().join("denied-wrapper"); - assert!(!build(&denied, false, true).status.success()); - assert!( - !denied.exists(), - "failed minting must not produce a release binary" - ); + // All three identity bindings are runtime controls: use the caller's key, + // project, and recording level instead of the embedded defaults. + ingest_api(&server).await; + let output = Command::new(temp.path().join("wrapper-0")) + .arg("--help") + .env("BAML_TELEMETRY", "medium") + .env("BOUNDARY_API_URL", server.uri()) + .env("BOUNDARY_API_KEY", "bdry_secret_runtime_override") + .env("BOUNDARY_PROJECT", "caller/project") + .env("DO_NOT_TRACK", "1") + .env("BAML_HOME", temp.path().join("home")) + .output() + .unwrap(); + succeeded(&output); + let requests = server.received_requests().await.unwrap(); + assert!(requests.iter().any(|request| request.method == "POST")); + for request in requests.iter().filter(|request| request.method == "POST") { + assert_eq!( + request.headers["authorization"], + "Bearer bdry_secret_runtime_override" + ); + let body: Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(body["target"], json!({"project":"caller/project"})); + assert_eq!(body.get("buildId"), None); + } } diff --git a/baml_language/crates/baml_exec/src/envelope.rs b/baml_language/crates/baml_exec/src/envelope.rs index 29d24336826..90ac9809a3b 100644 --- a/baml_language/crates/baml_exec/src/envelope.rs +++ b/baml_language/crates/baml_exec/src/envelope.rs @@ -70,106 +70,3 @@ pub struct PackEnvelope { /// Recording defaults and the runtime overrides permitted by the publisher. pub telemetry: btel_settings::artifact::ArtifactTelemetry, } - -impl PackEnvelope { - /// Dispatch metadata and the complete versioned program are part of the build. - pub fn telemetry_digest( - &self, - ) -> Result { - let mut unsigned = self.clone(); - unsigned.telemetry.embedded = None; - let payload = baml_artifact::encode(baml_artifact::ArtifactKind::PackedProgram, &unsigned) - .map_err(|_| { - btel_settings::artifact::PolicyError( - "Could not encode telemetry build fingerprint.".into(), - ) - })?; - btel_settings::artifact::build_digest(&payload, &unsigned.telemetry) - } - pub fn verify_telemetry(&self) -> Result<(), btel_settings::artifact::PolicyError> { - if let Some(embedded) = &self.telemetry.embedded { - if embedded.bytecode_digest != self.telemetry_digest()? { - return Err(btel_settings::artifact::PolicyError("Embedded telemetry does not match this packed artifact. Rebuild with `baml pack --embed-telemetry`.".into())); - } - if !embedded.is_well_formed() { - return Err(btel_settings::artifact::PolicyError("The embedded telemetry credential is invalid. Rebuild with `baml pack --embed-telemetry`.".into())); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use btel_settings::artifact::{ - ArtifactTelemetry, EmbeddedIngestion, IngestionDestination, PublicCredential, - }; - - use super::*; - - fn embedded_envelope() -> PackEnvelope { - let mut envelope = PackEnvelope { - program: Program::default(), - mode: PackMode::Single, - targets: vec![TargetEntry { - qualified_name: "user.main".into(), - display_name: "main".into(), - subcommand_name: "main".into(), - }], - output_format: OutputFormat::Json, - telemetry: ArtifactTelemetry::default(), - }; - envelope.telemetry.embedded = Some(Box::new(EmbeddedIngestion { - token: PublicCredential::new(format!("bdry_public_{}", "a".repeat(64))), - token_id: "token".into(), - build_id: "build".into(), - product_id: "product".into(), - destination: IngestionDestination { - org_id: "org".into(), - project_id: "project".into(), - environment_id: "environment".into(), - }, - bytecode_digest: envelope.telemetry_digest().unwrap(), - })); - envelope - } - - #[test] - fn packed_credentials_require_a_build_and_complete_destination() { - let valid = embedded_envelope(); - valid.verify_telemetry().unwrap(); - for field in [ - "token", - "build_id", - "org_id", - "project_id", - "environment_id", - ] { - let mut envelope = valid.clone(); - let embedded = envelope.telemetry.embedded.as_mut().unwrap(); - match field { - "token" => embedded.token = PublicCredential::new("bdry_secret_not_public".into()), - "build_id" => embedded.build_id.clear(), - "org_id" => embedded.destination.org_id.clear(), - "project_id" => embedded.destination.project_id.clear(), - "environment_id" => embedded.destination.environment_id.clear(), - _ => unreachable!(), - } - assert_eq!( - envelope.verify_telemetry().unwrap_err().to_string(), - r#"The embedded telemetry credential is invalid. Rebuild with `baml pack --embed-telemetry`."#, - "invalid {field} must fail even when the envelope digest matches" - ); - } - } - - #[test] - fn packed_fingerprint_still_covers_dispatch_metadata() { - let mut envelope = embedded_envelope(); - envelope.targets[0].qualified_name = "user.other".into(); - assert_eq!( - envelope.verify_telemetry().unwrap_err().to_string(), - r#"Embedded telemetry does not match this packed artifact. Rebuild with `baml pack --embed-telemetry`."# - ); - } -} diff --git a/baml_language/crates/baml_pack_host/src/lib.rs b/baml_language/crates/baml_pack_host/src/lib.rs index 5b472954982..57dc9958f8f 100644 --- a/baml_language/crates/baml_pack_host/src/lib.rs +++ b/baml_language/crates/baml_pack_host/src/lib.rs @@ -40,13 +40,7 @@ pub fn run_embedded( ) -> ExitCode { let envelope: Result<(PackEnvelope, [u8; 32]), String> = baml_artifact::decode_with_digest(baml_artifact::ArtifactKind::PackedProgram, artifact) - .map_err(|e| format!("Failed to deserialize pack envelope: {e}")) - .and_then(|(envelope, digest): (PackEnvelope, [u8; 32])| { - envelope - .verify_telemetry() - .map_err(|error| error.to_string())?; - Ok((envelope, digest)) - }); + .map_err(|e| format!("Failed to deserialize pack envelope: {e}")); match envelope { Ok((envelope, artifact_fingerprint)) => { let compiled_bodies = compiled_bodies diff --git a/baml_language/crates/bcs_api/README.md b/baml_language/crates/bcs_api/README.md index d0a77dfea64..39d30b25cc7 100644 --- a/baml_language/crates/bcs_api/README.md +++ b/baml_language/crates/bcs_api/README.md @@ -21,7 +21,7 @@ Credentials are sent only to their configured API endpoint. Upload bodies retain ## Embedded telemetry in artifacts -`baml pack --embed-telemetry` and `baml generate --embed-telemetry` provision a fresh public ingestion credential. Choose an existing environment with `--telemetry-environment=staging`, or set a producer default: +`baml pack main --embed-telemetry BAML_WRAPPER_BOUNDARY_API_KEY` and `baml generate --embed-telemetry BAML_WRAPPER_BOUNDARY_API_KEY` read the named environment variable at build time and embed its value as the telemetry API key. The variable must be set to a non-empty Unicode value. Embedding makes no cloud requests and does not read saved logins. It reads `BOUNDARY_API_KEY` only if that is the variable explicitly named. The resulting artifact contains the key itself; it does not need the source variable at runtime. The API key determines the ingestion destination. Configure the endpoint and runtime policy in `baml.toml`: ```toml [boundary] @@ -29,7 +29,6 @@ project = "publisher/app" api_url = "https://api.prod.bcs.boundaryml.com" [pack] -telemetry_environment = "staging" on_initial_telemetry_failure = "warn" initial_telemetry_warning_message = "Telemetry unavailable; continuing." http_bodies = false @@ -40,90 +39,15 @@ BOUNDARY_API_KEY = "ACME_BOUNDARY_API_KEY" BAML_TELEMETRY = "ACME_TELEMETRY" [bridge] -telemetry_environment = "staging" +on_initial_telemetry_failure = "warn" ``` -Pack and bridge settings are independent. The environment default is used only when `--embed-telemetry` is supplied. Build-time authentication uses `BOUNDARY_API_KEY` or the saved login for the selected endpoint. Authentication or build-token provisioning failure makes the pack or generate command fail. The server must require project administrator access or an API key with permission to mint for that target. +Pack and bridge settings are independent. An unset, empty, or non-Unicode source variable makes the pack or generate command fail before writing the artifact. Without `--embed-telemetry `, no API key is embedded. -An embedded credential fixes the destination by default. A publisher can explicitly name both destination variables, or set both to `false`. With named bindings, a project and API key select caller-owned ingestion; a project alone uses the caller's saved login and personal environment; a key alone lets the API infer its bound ingestion environment. The publisher's public token is never reused for a caller-selected destination. With no binding values set, the embedded credential remains selected. Without an embedded credential, omitted bindings accept the standard Boundary variables and saved login. +An embedded credential fixes the destination by default. A publisher can explicitly name both destination variables, or set both to `false`. With named bindings, a project and API key select caller-owned ingestion; a project alone uses the caller's saved login and personal environment; a key alone lets the API infer its bound ingestion environment. The publisher's embedded key is never reused for a caller-selected destination. With no binding values set, the embedded credential remains selected. Without an embedded credential, omitted bindings accept the standard Boundary variables and saved login. The recording-level binding is independently explicit: omission or `false` freezes the default `medium`; a string names the accepted variable. That variable takes `off`, `low`, `medium`, or `high`, case-insensitive; `false`, `0`, and `no` also mean `off`, and `true`, `1`, `yes`, and `on` keep the default. Empty means unset, and any other value turns recording off. `off` starts no cloud requests. A permitted API-key variable set to `local` selects local recording; empty destination variables are unset. `BOUNDARY_API_URL` always overrides the baked endpoint, including fixed embedded artifacts; an invalid URL records locally instead of stopping the artifact. Newly packed binaries and generated bridges default to `warn` for initial telemetry failures; publishers can explicitly choose `abort`, `warn`, or `ignore`. Authorization starts immediately in the background. Initial failure cancels BAML execution for `abort`; `warn` prints the configured message and continues; `ignore` continues silently. Bridge `abort` returns an operation error while the host process stays running. Ordinary caller-configured CLI cloud recording defaults to `abort` and exits nonzero on initial failure. After initial success, later telemetry failures remain nonfatal under every policy. At `medium` and `high`, network spans record request and response bodies, streamed events included. `http_bodies = false` keeps every body out of them at every level, for an API whose responses carry secrets such as session tokens. They still record the method, the sanitized URL and headers, the status, timing and whether the request failed, but not its error, which can quote the body. Model calls are network spans too, so this also keeps rendered prompts and raw model responses out; token usage is still recorded. Function spans are unchanged: they record arguments, results and errors as their trace policy says, and an error can carry a body, such as a model error's `raw_body`. Omission or `true` records bodies. - -### Build provisioning contract - -The native client implements this proposed contract. Artifact integration tests exercise it against a mock API; the BCS server must implement minting and public-credential admission before embedded cloud telemetry can be used with a deployed service. - -```http -POST /v1/build-tokens -Authorization: Bearer bdry_secret_ -Idempotency-Key: -Content-Type: application/json - -{ - "target": { - "kind": "handle", - "project": "publisher/app", - "environment": "staging" - }, - "bytecodeDigest": { - "version": 1, - "algorithm": "sha256", - "value": "<64 lowercase hex characters>" - } -} -``` - -The operation also accepts a user session or its cached access grant. Permissions are checked for this exact project/environment. Each invocation uses a new UUID and gets a fresh independently revocable token. A retry with the same UUID and request recovers the original result; a conflicting request fails. The response is: - -```json -{ - "buildId": "build_123", - "tokenId": "token_456", - "token": "bdry_public_", - "productId": "product_789", - "destination": { - "orgId": "org_123", - "projectId": "project_456", - "environmentId": "environment_789" - }, - "bytecodeDigest": { - "version": 1, - "algorithm": "sha256", - "value": "" - } -} -``` - -The credential is opaque, valid until revoked, and limited to ingestion into this destination. Its server registration associates the credential hash with the build, destination, and active state. The returned digest must exactly match the request. The artifact contains the public credential and registration; private minting credentials stay in the build process. - -Heartbeat and upload preparation carry the same authorization, with build context alongside the ordinary request target: - -```http -POST /v1/recordings//heartbeat -Authorization: Bearer bdry_public_ -Content-Type: application/json - -{ - "buildId": "build_123", - "target": { - "orgId": "org_123", - "projectId": "project_456", - "environmentId": "environment_789" - }, - "producer_session_id": "", - "liveness_sequence": 1, - "state": "RUNNING" -} -``` - -Upload preparation adds `buildId` and `target` to the existing prepare body. Upload bytes keep the protobuf envelope and use presigned capabilities. Caller overrides use their own credential and target, with the public build context omitted. The data plane must validate public registrations using its periodically refreshed authorization configuration and enforce matching build/destination, ingest-only authority, and active state. - -### Local build check - -Fingerprint version 1 uses SHA-256 over a domain separator, a length-prefixed artifact payload, and Borsh-encoded publisher policy. Generated bridges hash the complete versioned program artifact. Packed binaries hash the versioned dispatch envelope, including its program, entry points and output format. Versioned artifact bytes include the BAML toolchain fingerprint. Embedded credential/registration fields are removed before hashing, so minting another token can identify the same build. - -Hosts compare this digest before starting the artifact. This catches accidental bytecode/credential transplantation in the standard toolchain. The build ID associates reporting with a distributed build; it is not attestation that an HTTP caller executed that build. Embedded credentials are intentionally extractable and distributable. diff --git a/baml_language/crates/bcs_api/src/builds.rs b/baml_language/crates/bcs_api/src/builds.rs deleted file mode 100644 index 2f511399158..00000000000 --- a/baml_language/crates/bcs_api/src/builds.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Build-time provisioning of public ingestion credentials. These credentials -//! are opaque server registrations, with independent revocation and no expiry. -use serde::{Deserialize, Serialize}; - -use crate::{ - Client, Secret, - credentials::RequestAuthorization, - error::{Result, require}, -}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BytecodeDigest { - pub version: u32, - pub algorithm: String, - pub value: String, -} -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Destination { - pub org_id: String, - pub project_id: String, - pub environment_id: String, -} -#[derive(Debug, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum BuildTarget<'a> { - Handle { - project: &'a str, - environment: &'a str, - }, -} -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MintBuildToken<'a> { - pub target: BuildTarget<'a>, - pub bytecode_digest: &'a BytecodeDigest, -} -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MintedBuildToken { - pub build_id: String, - pub token_id: String, - pub token: Secret, - pub product_id: String, - pub destination: Destination, - pub bytecode_digest: BytecodeDigest, -} - -impl Client { - /// A fresh idempotency key belongs to each explicit invocation. Retrying this - /// call with that same key recovers the original credential rather than minting another. - pub fn mint_build_token( - &self, - authorization: &RequestAuthorization, - idempotency_key: uuid::Uuid, - request: &MintBuildToken<'_>, - ) -> Result { - let response = self.send_authorized(authorization, || { - self.http - .post(format!("{}/v1/build-tokens", self.endpoint.as_str())) - .header("Idempotency-Key", idempotency_key.to_string()) - .json(request) - })?; - let minted: MintedBuildToken = Self::read_json(response)?; - let suffix = minted - .token - .expose() - .strip_prefix(crate::credentials::PUBLIC_PREFIX); - require( - suffix.is_some_and(|value| { - (value.len() == 43 || value.len() == 64) - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') - }) && !minted.build_id.is_empty() - && !minted.token_id.is_empty() - && !minted.product_id.is_empty() - && !minted.destination.org_id.is_empty() - && !minted.destination.project_id.is_empty() - && !minted.destination.environment_id.is_empty() - && &minted.bytecode_digest == request.bytecode_digest, - "Boundary returned an invalid build-token registration", - )?; - Ok(minted) - } -} - -#[cfg(test)] -mod tests { - use wiremock::{ - Mock, MockServer, ResponseTemplate, - matchers::{body_json, header, method, path}, - }; - - use super::*; - use crate::{ - Endpoint, - credentials::{ACCESS_EXPIRY_HEADER, ACCESS_TOKEN_HEADER, Authentication}, - }; - - fn registration(digest: &BytecodeDigest) -> serde_json::Value { - serde_json::json!({"buildId":"build", "tokenId":"token", "token":format!("bdry_public_{}", "a".repeat(64)), - "productId":"product", "destination":{"orgId":"org","projectId":"project","environmentId":"env"}, "bytecodeDigest":digest}) - } - #[tokio::test] - async fn mint_uses_operation_auth_and_preserves_the_invocations_idempotency_key() { - let server = MockServer::start().await; - let digest = BytecodeDigest { - version: 1, - algorithm: "sha256".into(), - value: "d".repeat(64), - }; - let key = uuid::Uuid::new_v4(); - let body = serde_json::json!({"target":{"kind":"handle","project":"publisher/app","environment":"staging"},"bytecodeDigest":digest}); - let expires = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - + 250; - Mock::given(method("POST")) - .and(path("/v1/build-tokens")) - .and(header("Authorization", "Bearer bdry_session_admin")) - .and(header("Idempotency-Key", key.to_string())) - .and(body_json(body.clone())) - .respond_with( - ResponseTemplate::new(201) - .insert_header(ACCESS_TOKEN_HEADER, "bdry_access_admin") - .insert_header(ACCESS_EXPIRY_HEADER, expires.to_string()) - .set_body_json(registration(&digest)), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/build-tokens")) - .and(header("Authorization", "Bearer bdry_access_admin")) - .and(header("Idempotency-Key", key.to_string())) - .and(body_json(body)) - .respond_with(ResponseTemplate::new(201).set_body_json(registration(&digest))) - .expect(1) - .mount(&server) - .await; - let endpoint = Endpoint::parse(&server.uri()).unwrap(); - tokio::task::spawn_blocking(move || { - let authorization = RequestAuthorization { - authentication: Authentication::shared( - endpoint.as_str(), - Secret::new("bdry_session_admin".into()), - ), - target: None, - }; - let client = Client::new(endpoint).unwrap(); - let request = MintBuildToken { - target: BuildTarget::Handle { - project: "publisher/app", - environment: "staging", - }, - bytecode_digest: &digest, - }; - let first = client - .mint_build_token(&authorization, key, &request) - .unwrap(); - let retry = client - .mint_build_token(&authorization, key, &request) - .unwrap(); - assert_eq!(first.token.expose(), retry.token.expose()); - assert_eq!(first.build_id, retry.build_id); - assert_eq!(format!("{:?}", first.token), "[REDACTED]"); - }) - .await - .unwrap(); - } - #[tokio::test] - async fn mint_rejects_mismatched_or_nonpublic_registration_without_exposing_tokens() { - let server = MockServer::start().await; - let digest = BytecodeDigest { - version: 1, - algorithm: "sha256".into(), - value: "d".repeat(64), - }; - let mut invalid = registration(&digest); - invalid["token"] = "bdry_secret_private-never-embed".into(); - Mock::given(method("POST")) - .and(path("/v1/build-tokens")) - .respond_with(ResponseTemplate::new(201).set_body_json(invalid)) - .expect(1) - .mount(&server) - .await; - let endpoint = Endpoint::parse(&server.uri()).unwrap(); - tokio::task::spawn_blocking(move || { - let authorization = RequestAuthorization { - authentication: Authentication::shared( - endpoint.as_str(), - Secret::new("bdry_secret_builder".into()), - ), - target: None, - }; - let client = Client::new(endpoint).unwrap(); - let error = client - .mint_build_token( - &authorization, - uuid::Uuid::new_v4(), - &MintBuildToken { - target: BuildTarget::Handle { - project: "publisher/app", - environment: "staging", - }, - bytecode_digest: &digest, - }, - ) - .unwrap_err(); - assert_eq!( - error.to_string(), - r#"Boundary returned an invalid build-token registration"# - ); - }) - .await - .unwrap(); - } - #[tokio::test] - async fn public_authorization_sends_build_context_on_heartbeat() { - let server = MockServer::start().await; - let token = format!("bdry_public_{}", "a".repeat(64)); - let authorization = RequestAuthorization { - authentication: Authentication::for_build( - &server.uri(), - Secret::new(token.clone()), - "build".into(), - ), - target: Some(crate::credentials::Target { - environment_id: Some("env".into()), - ..Default::default() - }), - }; - Mock::given(method("POST")) - .and(path("/heartbeat")) - .and(header("Authorization", format!("Bearer {token}"))) - .and(wiremock::matchers::body_partial_json( - serde_json::json!({"buildId":"build","target":{"environmentId":"env"}}), - )) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - crate::telemetry::heartbeat( - &reqwest::Client::new(), - &format!("{}/heartbeat", server.uri()).parse().unwrap(), - None, - Some(&authorization), - &crate::wire::Liveness { - producer_session_id: uuid::Uuid::new_v4(), - liveness_sequence: 1, - state: crate::wire::ProducerState::Running, - }, - ) - .await - .unwrap(); - } -} diff --git a/baml_language/crates/bcs_api/src/credentials.rs b/baml_language/crates/bcs_api/src/credentials.rs index 3d64a356c74..042fa528bc3 100644 --- a/baml_language/crates/bcs_api/src/credentials.rs +++ b/baml_language/crates/bcs_api/src/credentials.rs @@ -13,7 +13,6 @@ use sha2::{Digest, Sha256}; pub const SESSION_PREFIX: &str = "bdry_session_"; pub const ACCESS_PREFIX: &str = "bdry_access_"; pub const SECRET_PREFIX: &str = "bdry_secret_"; -pub const PUBLIC_PREFIX: &str = "bdry_public_"; /// Reserved host selection value; it must never be sent as a bearer credential. pub const LOCAL_API_KEY: &str = "local"; pub const ACCESS_TOKEN_HEADER: &str = "boundary-access-token"; @@ -73,7 +72,6 @@ impl Cache { struct State { endpoint: String, credential: Secret, - build_id: Option, cache: Mutex, } @@ -81,35 +79,25 @@ struct State { /// endpoint and original credential. Neither credentials nor access grants enter diagnostics. #[derive(Clone, Debug)] pub struct Authentication(Arc); -type CacheKey = (String, [u8; 32], Option); +type CacheKey = (String, [u8; 32]); type Registry = HashMap>; static AUTHENTICATIONS: OnceLock> = OnceLock::new(); impl Authentication { pub fn shared(endpoint: &str, credential: Secret) -> Self { - Self::shared_context(endpoint, credential, None) - } - pub fn for_build(endpoint: &str, credential: Secret, build_id: String) -> Self { - Self::shared_context(endpoint, credential, Some(build_id)) - } - pub fn build_id(&self) -> Option<&str> { - self.0.build_id.as_deref() - } - fn shared_context(endpoint: &str, credential: Secret, build_id: Option) -> Self { let hash: [u8; 32] = Sha256::digest(credential.expose().as_bytes()).into(); let mut registry = AUTHENTICATIONS .get_or_init(Mutex::default) .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); registry.retain(|_, state| state.strong_count() > 0); - let key = (endpoint.trim_end_matches('/').to_owned(), hash, build_id); + let key = (endpoint.trim_end_matches('/').to_owned(), hash); if let Some(state) = registry.get(&key).and_then(Weak::upgrade) { return Self(state); } let state = Arc::new(State { endpoint: key.0.clone(), credential, - build_id: key.2.clone(), cache: Mutex::new(Cache::default()), }); registry.insert(key, Arc::downgrade(&state)); @@ -360,8 +348,6 @@ pub struct RequestAuthorization { #[derive(Serialize)] pub struct Targeted<'a, T> { - #[serde(rename = "buildId", skip_serializing_if = "Option::is_none")] - pub build_id: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option<&'a Target>, #[serde(flatten)] diff --git a/baml_language/crates/bcs_api/src/diagnostics.rs b/baml_language/crates/bcs_api/src/diagnostics.rs index 4729b8dfdf3..e6d156df879 100644 --- a/baml_language/crates/bcs_api/src/diagnostics.rs +++ b/baml_language/crates/bcs_api/src/diagnostics.rs @@ -16,7 +16,6 @@ pub enum Operation { Ingest, Query, Authentication, - MintBuildToken, } impl Operation { fn description(self) -> &'static str { @@ -24,7 +23,6 @@ impl Operation { Self::Ingest => "cloud telemetry ingestion", Self::Query => "the cloud query", Self::Authentication => "the authentication request", - Self::MintBuildToken => "telemetry credential minting", } } } @@ -34,7 +32,6 @@ pub enum Outcome { ExecutionCancelled, QueryFailed, AuthenticationFailed, - ArtifactBuildFailed, TelemetryDisabled, } impl fmt::Display for Outcome { @@ -43,7 +40,6 @@ impl fmt::Display for Outcome { Self::ExecutionCancelled => "Execution cancelled.", Self::QueryFailed => "Query failed.", Self::AuthenticationFailed => "Authentication command failed.", - Self::ArtifactBuildFailed => "Artifact build failed.", Self::TelemetryDisabled => "Cloud telemetry disabled. Program execution continues.", }) } @@ -248,7 +244,7 @@ impl Diagnostic { if self.context.source == CredentialSource::Embedded { writeln!( f, - " • Ask the publisher to rebuild with `--embed-telemetry` and an active ingestion credential." + " • Ask the publisher to rebuild with `--embed-telemetry ` and an active API key." )?; if let RecordingOverride::Artifact { project: Some(project), @@ -422,7 +418,7 @@ impl fmt::Display for Diagnostic { ) } } - Operation::Authentication | Operation::MintBuildToken => { + Operation::Authentication => { writeln!(f, " • Check the requested project and environment.") } }?; @@ -450,7 +446,6 @@ impl fmt::Display for Diagnostic { } else { let permission = match operation { Operation::Ingest => "ingestion", - Operation::MintBuildToken => "minting (or project administrator access)", _ => "query", }; writeln!( @@ -539,7 +534,7 @@ impl fmt::Display for Diagnostic { f, " • Query local recordings: rerun with BOUNDARY_API_KEY=local or --local." )?, - Operation::Authentication | Operation::MintBuildToken => {} + Operation::Authentication => {} } write!(f, "\n {}", self.outcome) } @@ -1007,7 +1002,7 @@ mod artifact_diagnostics_tests { r#"Boundary rejected the embedded telemetry credential (401). To continue, choose one: - • Ask the publisher to rebuild with `--embed-telemetry` and an active ingestion credential. + • Ask the publisher to rebuild with `--embed-telemetry ` and an active API key. Execution cancelled."# ); @@ -1031,7 +1026,7 @@ mod artifact_diagnostics_tests { r#"Boundary denied cloud telemetry ingestion (HTTP 403). To continue, choose one: - • Ask the publisher to rebuild with `--embed-telemetry` and an active ingestion credential. + • Ask the publisher to rebuild with `--embed-telemetry ` and an active API key. • Set ACME_KEY to your own Boundary API key, or run `baml auth login` and set ACME_PROJECT=org_handle/project_name. • Record locally: rerun with ACME_KEY=local. • Disable recording: rerun with ACME_TELEMETRY=off. diff --git a/baml_language/crates/bcs_api/src/lib.rs b/baml_language/crates/bcs_api/src/lib.rs index 35182ea42ab..9fa1b5cf7de 100644 --- a/baml_language/crates/bcs_api/src/lib.rs +++ b/baml_language/crates/bcs_api/src/lib.rs @@ -5,8 +5,6 @@ #[cfg(feature = "auth")] pub mod auth; -#[cfg(feature = "auth")] -pub mod builds; pub mod credentials; pub mod response; pub use response::{ApiErrorBody, HttpFailure}; diff --git a/baml_language/crates/bcs_api/src/telemetry.rs b/baml_language/crates/bcs_api/src/telemetry.rs index cc314a04f6f..f0e6e63f1e1 100644 --- a/baml_language/crates/bcs_api/src/telemetry.rs +++ b/baml_language/crates/bcs_api/src/telemetry.rs @@ -38,7 +38,6 @@ pub async fn heartbeat( body: &Liveness, ) -> Result { let targeted = Targeted { - build_id: authorization.and_then(|auth| auth.authentication.build_id()), target: authorization.and_then(|auth| auth.target.as_ref()), payload: body, }; diff --git a/baml_language/crates/bex_engine/src/telemetry.rs b/baml_language/crates/bex_engine/src/telemetry.rs index 067bd1ba04d..4a19585bce3 100644 --- a/baml_language/crates/bex_engine/src/telemetry.rs +++ b/baml_language/crates/bex_engine/src/telemetry.rs @@ -178,6 +178,7 @@ impl TelemetryRecording { .select_ingestion_with(baml_env::os_var) .map_err(|error| EngineError::Other(error.to_string()))?; if matches!(&selection, IngestionSelection::Caller { api_key: Some(key), .. } if key == bcs_api::credentials::LOCAL_API_KEY) + || matches!(&selection, IngestionSelection::Embedded(embedded) if embedded.api_key.expose() == bcs_api::credentials::LOCAL_API_KEY) { return Ok(Self::user_files(RecordingConfig::default())); } @@ -190,26 +191,15 @@ impl TelemetryRecording { return Ok(Self::user_files(RecordingConfig::default())); } }; - let (credential, target, source, build_id) = match selection { + let (credential, target, source) = match selection { IngestionSelection::Embedded(embedded) => ( - embedded.token.expose().to_owned(), - bcs_api::credentials::Target { - org_id: Some(embedded.destination.org_id.clone()), - project_id: Some(embedded.destination.project_id.clone()), - environment_id: Some(embedded.destination.environment_id.clone()), - ..Default::default() - }, + embedded.api_key.expose().to_owned(), + bcs_api::credentials::Target::default(), CredentialSource::Embedded, - Some(embedded.build_id.clone()), ), IngestionSelection::Caller { project, api_key } => { let (credential, source) = match api_key { - Some(key) => { - if key.starts_with(bcs_api::credentials::PUBLIC_PREFIX) { - return Err(EngineError::Other("A public telemetry credential must be embedded with its build registration. Supply a Boundary API key or use `baml auth login`.".into())); - } - (key, CredentialSource::ApiKey) - } + Some(key) => (key, CredentialSource::ApiKey), None => { let saved = match bcs_api::Store::new(&endpoint).and_then(|store| store.read()) { @@ -249,21 +239,13 @@ impl TelemetryRecording { ..Default::default() }, source, - None, ) } }; - let authentication = match build_id { - Some(build) => bcs_api::credentials::Authentication::for_build( - endpoint.as_str(), - bcs_api::Secret::new(credential), - build, - ), - None => bcs_api::credentials::Authentication::shared( - endpoint.as_str(), - bcs_api::Secret::new(credential), - ), - }; + let authentication = bcs_api::credentials::Authentication::shared( + endpoint.as_str(), + bcs_api::Secret::new(credential), + ); let mut delivery = btel_bcs::delivery::DeliveryConfig::new( endpoint .as_str() diff --git a/baml_language/crates/bridge_cffi/src/lib.rs b/baml_language/crates/bridge_cffi/src/lib.rs index bd24c993d57..3a2cb19bf0a 100644 --- a/baml_language/crates/bridge_cffi/src/lib.rs +++ b/baml_language/crates/bridge_cffi/src/lib.rs @@ -206,9 +206,6 @@ fn prepare_runtime_from_blob( )) })?; if let Some(policy) = policy { - policy - .verify_build(&bytecode) - .map_err(|error| BridgeError::Startup(format!("{context}{error}")))?; runtime.with_artifact_telemetry(policy) } else { runtime diff --git a/baml_language/crates/bridge_cffi/tests/artifact_telemetry.rs b/baml_language/crates/bridge_cffi/tests/artifact_telemetry.rs index e8be471d3f3..d980c2223e4 100644 --- a/baml_language/crates/bridge_cffi/tests/artifact_telemetry.rs +++ b/baml_language/crates/bridge_cffi/tests/artifact_telemetry.rs @@ -148,15 +148,14 @@ async fn run_generated_main(recording_level_envvar: Option) { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { +async fn bridge_embedded_ingestion_respects_bindings() { use btel_settings::artifact::{ - ArtifactTelemetry, DestinationOverrides, EmbeddedIngestion, IngestionDestination, - PublicCredential, build_digest, + ArtifactTelemetry, DestinationOverrides, EmbeddedCredential, EmbeddedIngestion, }; const SCENARIO: &str = "BAML_TEST_BRIDGE_EMBEDDED"; let Some(scenario) = baml_env::raw_var(SCENARIO) else { use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; - for scenario in ["fixed", "custom", "off", "mismatch"] { + for scenario in ["fixed", "custom", "off"] { let server = MockServer::start().await; Mock::given(method("POST")) .respond_with(ResponseTemplate::new(401)) @@ -165,7 +164,7 @@ async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", - "bridge_embedded_ingestion_respects_binding_and_build_boundaries", + "bridge_embedded_ingestion_respects_bindings", "--nocapture", ]) .env(SCENARIO, scenario) @@ -187,7 +186,7 @@ async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { String::from_utf8_lossy(&output.stderr) ); let requests = server.received_requests().await.unwrap(); - if matches!(scenario, "off" | "mismatch") { + if scenario == "off" { assert_eq!(requests.len(), 0); continue; } @@ -197,13 +196,10 @@ async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { if scenario == "fixed" { assert_eq!( request.headers["authorization"], - format!("Bearer bdry_public_{}", "a".repeat(64)) - ); - assert_eq!(body["buildId"], "build-bridge"); - assert_eq!( - body["target"], - serde_json::json!({"orgId":"o","projectId":"p","environmentId":"e"}) + "Bearer bdry_secret_embedded" ); + assert_eq!(body.get("buildId"), None); + assert_eq!(body["target"], serde_json::json!({})); } else { assert_eq!( request.headers["authorization"], @@ -240,22 +236,9 @@ async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { }, ..Default::default() }; - let digest = build_digest(&bytecode, &policy).unwrap(); policy.embedded = Some(Box::new(EmbeddedIngestion { - token: PublicCredential::new(format!("bdry_public_{}", "a".repeat(64))), - token_id: "token-bridge".into(), - build_id: "build-bridge".into(), - product_id: "product-bridge".into(), - destination: IngestionDestination { - org_id: "o".into(), - project_id: "p".into(), - environment_id: "e".into(), - }, - bytecode_digest: digest, + api_key: EmbeddedCredential::new("bdry_secret_embedded".into()), })); - if scenario == "mismatch" { - policy.embedded.as_mut().unwrap().bytecode_digest.value = "f".repeat(64); - } let metadata = toml::Table::from_iter([ ("metadata_version".into(), 1.into()), ( @@ -274,22 +257,6 @@ async fn bridge_embedded_ingestion_respects_binding_and_build_boundaries() { .unwrap(); let blob = baml_artifact::encode_embedded(&bytecode); let runtime = bridge_cffi::initialize_runtime_from_blob(blob.as_bytes(), Some(&manifest)); - if scenario == "mismatch" { - let Err(error) = runtime else { - panic!("mismatched build must fail before auth") - }; - assert_eq!( - error.to_string(), - format!( - r#"BAML startup failed: generated SDK bytecode could not be loaded. - -`baml_sdk` generated using BAML toolchain {}, could not be loaded by baml-bridge-test {}: Embedded telemetry does not match this artifact. Rebuild with `baml pack --embed-telemetry` or `baml generate --embed-telemetry`."#, - baml_version::CANONICAL_VERSION, - baml_version::CANONICAL_VERSION - ) - ); - return; - } let runtime = runtime.unwrap(); let args = CallFunctionArgs { call_id: bridge_cffi::new_function_call_id(), diff --git a/baml_language/crates/btel_bcs/src/delivery.rs b/baml_language/crates/btel_bcs/src/delivery.rs index 19374f79be7..7f3d0f19c2b 100644 --- a/baml_language/crates/btel_bcs/src/delivery.rs +++ b/baml_language/crates/btel_bcs/src/delivery.rs @@ -1303,10 +1303,6 @@ async fn prepare( serde_json::to_writer( &mut writer, &bcs_api::credentials::Targeted { - build_id: config - .authorization - .as_ref() - .and_then(|auth| auth.authentication.build_id()), target: config .authorization .as_ref() diff --git a/baml_language/crates/btel_settings/Cargo.toml b/baml_language/crates/btel_settings/Cargo.toml index 47d2b8f6d1b..e80f2c72a91 100644 --- a/baml_language/crates/btel_settings/Cargo.toml +++ b/baml_language/crates/btel_settings/Cargo.toml @@ -21,7 +21,6 @@ workspace = true baml_env = { workspace = true } borsh = { workspace = true } serde = { workspace = true } -sha2 = { workspace = true } strsim = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } diff --git a/baml_language/crates/btel_settings/src/artifact.rs b/baml_language/crates/btel_settings/src/artifact.rs index 24afc91d551..c7981e7353e 100644 --- a/baml_language/crates/btel_settings/src/artifact.rs +++ b/baml_language/crates/btel_settings/src/artifact.rs @@ -15,7 +15,6 @@ use std::{ffi::OsString, fmt, fmt::Write as _}; use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::mode::AutoTelemetryLevel; @@ -136,11 +135,11 @@ pub enum DestinationOverrides { }, } -/// Public, distributable ingestion credential. Debug output still redacts it. +/// API key embedded by the publisher. Debug output redacts it. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[serde(transparent)] -pub struct PublicCredential(String); -impl PublicCredential { +pub struct EmbeddedCredential(String); +impl EmbeddedCredential { pub fn new(value: String) -> Self { Self(value) } @@ -148,52 +147,19 @@ impl PublicCredential { &self.0 } } -impl fmt::Debug for PublicCredential { +impl fmt::Debug for EmbeddedCredential { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("[REDACTED]") } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct BytecodeDigest { - pub version: u32, - pub algorithm: String, - pub value: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct IngestionDestination { - pub org_id: String, - pub project_id: String, - pub environment_id: String, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EmbeddedIngestion { - pub token: PublicCredential, - pub token_id: String, - pub build_id: String, - pub product_id: String, - pub destination: IngestionDestination, - pub bytecode_digest: BytecodeDigest, -} - -impl EmbeddedIngestion { - /// Check the credential, build identity and concrete ingestion target. - /// Each artifact format verifies its fingerprint separately. - pub fn is_well_formed(&self) -> bool { - self.token.expose().starts_with("bdry_public_") - && !self.build_id.is_empty() - && !self.destination.org_id.is_empty() - && !self.destination.project_id.is_empty() - && !self.destination.environment_id.is_empty() - } + pub api_key: EmbeddedCredential, } -/// The public credential is never reused when the caller selects a destination. +/// The embedded key is never reused when the caller selects a destination. #[derive(Debug, PartialEq, Eq)] pub enum IngestionSelection<'a> { Embedded(&'a EmbeddedIngestion), @@ -203,28 +169,6 @@ pub enum IngestionSelection<'a> { }, } -/// Fingerprint v1 covers the exact versioned program/dispatch bytes and publisher -/// policy. Credentials are excluded; fresh tokens can identify the same build. -pub fn build_digest( - payload: &[u8], - policy: &ArtifactTelemetry, -) -> Result { - let mut unsigned = policy.clone(); - unsigned.embedded = None; - let policy_bytes = borsh::to_vec(&unsigned) - .map_err(|_| PolicyError("Could not encode artifact telemetry policy.".into()))?; - let mut hash = Sha256::new(); - hash.update(b"baml-telemetry-build-v1\0"); - hash.update((payload.len() as u64).to_le_bytes()); - hash.update(payload); - hash.update(policy_bytes); - Ok(BytecodeDigest { - version: 1, - algorithm: "sha256".into(), - value: format!("{:x}", hash.finalize()), - }) -} - #[derive(Clone, Copy, Debug)] pub enum ArtifactKind { Pack, @@ -247,13 +191,12 @@ pub struct PolicyError(pub String); const PRODUCER_FIELDS: &[&str] = &[ "on_initial_telemetry_failure", "initial_telemetry_warning_message", - "telemetry_environment", "http_bodies", "env_var_names", ]; const ENVVAR_FIELDS: &[&str] = &["BAML_TELEMETRY", "BOUNDARY_PROJECT", "BOUNDARY_API_KEY"]; -/// Validate publisher-owned namespaces before any build can provision credentials. +/// Validate publisher-owned namespaces before building artifacts. /// Other manifest tables (package, generators, etc.) have their own parsers. fn validate_manifest_settings(manifest: &toml::Value) -> Result<(), PolicyError> { for (name, fields) in [ @@ -276,13 +219,6 @@ fn validate_manifest_settings(manifest: &toml::Value) -> Result<(), PolicyError> .ok_or_else(|| PolicyError(format!("[{name}] must be a table.")))?; validate_fields(table, &name, ENVVAR_FIELDS)?; } - if let Some(value) = table.get("telemetry_environment") { - if value.as_str().is_none_or(str::is_empty) { - return Err(PolicyError(format!( - "{name}.telemetry_environment must be a non-empty string." - ))); - } - } } } Ok(()) @@ -471,18 +407,6 @@ impl ArtifactTelemetry { Ok(IngestionSelection::Caller { project, api_key }) } - pub fn verify_build(&self, payload: &[u8]) -> Result<(), PolicyError> { - if let Some(embedded) = &self.embedded { - if embedded.bytecode_digest != build_digest(payload, self)? { - return Err(PolicyError("Embedded telemetry does not match this artifact. Rebuild with `baml pack --embed-telemetry` or `baml generate --embed-telemetry`.".into())); - } - if !embedded.is_well_formed() { - return Err(PolicyError("The embedded telemetry credential is invalid. Rebuild the artifact with `--embed-telemetry`.".into())); - } - } - Ok(()) - } - pub fn resolve_level(&self) -> Option { self.resolve_level_with(baml_env::os_var) } @@ -537,7 +461,6 @@ on_inital_telemetry_failure = "ignore""#, Valid [pack] settings: on_initial_telemetry_failure initial_telemetry_warning_message - telemetry_environment http_bodies env_var_names"#, ), @@ -549,7 +472,6 @@ extra = true"#, Valid [bridge] settings: on_initial_telemetry_failure initial_telemetry_warning_message - telemetry_environment http_bodies env_var_names"#, ), @@ -578,7 +500,7 @@ api_ur = "https://api.cloud.boundaryml.com""#, ), ] { let manifest: toml::Value = input.parse().unwrap(); - // Both producers reject malformed publisher settings before minting. + // Both producers reject malformed publisher settings before building. for kind in [ArtifactKind::Pack, ArtifactKind::Bridge] { assert_eq!( ArtifactTelemetry::from_manifest(&manifest, kind) @@ -590,25 +512,6 @@ api_ur = "https://api.cloud.boundaryml.com""#, } } - #[test] - fn telemetry_environment_is_validated_even_when_unused_or_overridden() { - for name in ["pack", "bridge"] { - for value in ["42", "false", "\"\"", "{}"] { - let manifest: toml::Value = format!("[{name}]\ntelemetry_environment = {value}") - .parse() - .unwrap(); - for kind in [ArtifactKind::Pack, ArtifactKind::Bridge] { - assert_eq!( - ArtifactTelemetry::from_manifest(&manifest, kind) - .unwrap_err() - .to_string(), - format!(r#"{name}.telemetry_environment must be a non-empty string."#) - ); - } - } - } - } - #[test] fn omitted_and_false_never_read_ambient_variables() { for kind in [ArtifactKind::Pack, ArtifactKind::Bridge] { @@ -790,19 +693,10 @@ on_initial_telemetry_failure = "required""#, #[cfg(test)] mod ingestion_tests { use super::*; - fn embedded(policy: &ArtifactTelemetry, payload: &[u8]) -> Box { - Box::new(EmbeddedIngestion { - token: PublicCredential::new(format!("bdry_public_{}", "a".repeat(64))), - token_id: "token".into(), - build_id: "build".into(), - product_id: "product".into(), - destination: IngestionDestination { - org_id: "o".into(), - project_id: "p".into(), - environment_id: "e".into(), - }, - bytecode_digest: build_digest(payload, policy).unwrap(), - }) + fn embedded() -> EmbeddedIngestion { + EmbeddedIngestion { + api_key: EmbeddedCredential::new("bdry_secret_embedded".into()), + } } #[test] fn embedded_defaults_do_not_read_ambient_identity_or_destination() { @@ -814,7 +708,7 @@ mod ingestion_tests { destination_overrides: overrides, ..Default::default() }; - policy.embedded = Some(embedded(&policy, b"program")); + policy.embedded = Some(Box::new(embedded())); assert_eq!( policy .select_ingestion_with(|_| panic!("publisher credentials are fixed")) @@ -824,7 +718,7 @@ mod ingestion_tests { } } #[test] - fn explicit_bindings_select_caller_credentials_without_reusing_public_token() { + fn explicit_bindings_select_caller_credentials_without_reusing_embedded_key() { let mut policy = ArtifactTelemetry { project: Some("publisher/app".into()), destination_overrides: DestinationOverrides::Named { @@ -833,7 +727,7 @@ mod ingestion_tests { }, ..Default::default() }; - policy.embedded = Some(embedded(&policy, b"program")); + policy.embedded = Some(Box::new(embedded())); for (project, key, expected_project) in [ ( Some("customer/app"), @@ -928,50 +822,4 @@ mod ingestion_tests { ); } } - #[test] - fn incomplete_embedded_destinations_fail_local_validation() { - for field in ["org_id", "project_id", "environment_id"] { - let mut policy = ArtifactTelemetry::default(); - let mut credential = embedded(&policy, b"program"); - match field { - "org_id" => credential.destination.org_id.clear(), - "project_id" => credential.destination.project_id.clear(), - "environment_id" => credential.destination.environment_id.clear(), - _ => unreachable!(), - } - policy.embedded = Some(credential); - assert_eq!( - policy.verify_build(b"program").unwrap_err().to_string(), - r#"The embedded telemetry credential is invalid. Rebuild the artifact with `--embed-telemetry`."#, - "empty {field} must fail even when the bytecode digest matches" - ); - } - } - #[test] - fn fingerprint_excludes_credentials_and_rejects_other_programs_or_policy() { - let mut policy = ArtifactTelemetry::default(); - let original = build_digest(b"complete bytecode", &policy).unwrap(); - policy.embedded = Some(embedded(&policy, b"complete bytecode")); - assert_eq!( - original, - build_digest(b"complete bytecode", &policy).unwrap() - ); - policy.verify_build(b"complete bytecode").unwrap(); - assert_eq!( - policy - .verify_build(b"other bytecode") - .unwrap_err() - .to_string(), - r#"Embedded telemetry does not match this artifact. Rebuild with `baml pack --embed-telemetry` or `baml generate --embed-telemetry`."# - ); - policy.recording_level = RecordingLevel::Off; - assert_ne!( - original, - build_digest(b"complete bytecode", &policy).unwrap() - ); - assert_eq!( - format!("{:?}", policy.embedded.unwrap().token), - "[REDACTED]" - ); - } } diff --git a/baml_language/wrapper/README.md b/baml_language/wrapper/README.md index 15b7a636b5a..d30cff38836 100644 --- a/baml_language/wrapper/README.md +++ b/baml_language/wrapper/README.md @@ -6,9 +6,9 @@ The code is organized around the data each operation owns: `WrapperInvocation` p Build with `python3 baml_language/wrapper/build --cli /absolute/path/to/baml-cli --output /absolute/path/to/baml`. The compiler and adjacent pack host must come from this checkout. `--no-self-update` produces a package-manager variant. The wrapper version comes from the Rust wrapper's Cargo manifest. Builds prepare a temporary project with those constants and invoke `baml pack`; no source files are rewritten. For cross-target packaging, pass `--target --pack-host /path/to/the/matching/baml-pack-host` to embed the host built from the same commit without fetching a released runtime. -Release builds pass `--embed-telemetry --telemetry-environment ` to this build helper. Set `BOUNDARY_PROJECT=org/project` and authenticate the builder with a mint-capable `BOUNDARY_API_KEY` or saved administrator login. Packing provisions and embeds a public BCS ingestion credential; the builder’s private credential is not included. Minting failure fails the build. Local and test builds omit `--embed-telemetry` and remain usable without BCS credentials. The packed wrapper accepts `BAML_WRAPPER_BCS_TELEMETRY=off` to disable BCS recording, and excludes HTTP bodies from network spans. This recording is separate from the wrapper-owned PostHog pipeline described below. The wrapper uses the existing packed-runtime BCS dispatch and shutdown policy, including its normal bounded delivery drain. It sets `on_initial_telemetry_failure = "ignore"` using the existing artifact policy. PostHog delivery retains its independent durable queue and detached helper. The Rust wrapper emits PostHog events only and does not initialize BCS recording. +Release builds pass `--embed-telemetry BAML_WRAPPER_BOUNDARY_API_KEY` to this build helper. Packing embeds that environment variable's value directly as the BCS telemetry API key, without a token exchange or build-time cloud request. A missing or empty value fails the build. Local and test builds omit `--embed-telemetry` and remain usable without BCS credentials. The packed wrapper uses identity bindings for `BAML_TELEMETRY`, `BOUNDARY_API_KEY`, and `BOUNDARY_PROJECT`: `BAML_TELEMETRY=off` disables BCS recording, and the other two variables select caller-owned ingestion instead of the embedded key. `BOUNDARY_API_URL` also overrides the baked endpoint. The wrapper excludes HTTP bodies from network spans. This recording is separate from the wrapper-owned PostHog pipeline described below. The wrapper uses the existing packed-runtime BCS dispatch and shutdown policy, including its normal bounded delivery drain. It sets `on_initial_telemetry_failure = "ignore"` using the existing artifact policy. PostHog delivery retains its independent durable queue and detached helper. The Rust wrapper emits PostHog events only and does not initialize BCS recording. -The release workflow reads `BAML_WRAPPER_BCS_PROJECT` (`org/project`) and `BAML_WRAPPER_BCS_ENVIRONMENT` (an existing environment) from GitHub variables, and `BAML_WRAPPER_BOUNDARY_API_KEY` from the `infisical-github-baml-language-release` environment. The caller passes that secret by name to the reusable workflow, whose environment-bound packing job exposes it to the builder as `BOUNDARY_API_KEY`. Configure those values before running wrapper release packaging; missing configuration and rejected minting fail the job before publishing candidate archives. Both self-update variants and every target receive their own embedded credential. Wrapper test fixtures set `BAML_WRAPPER_BCS_TELEMETRY=off` when exercising uploaded release binaries. +The release workflow reads `BAML_WRAPPER_BOUNDARY_API_KEY` from the `infisical-github-baml-language-release` environment and exposes the same name to the builder. Configure that secret before running wrapper release packaging. No project or telemetry-environment variables are required for embedding. Both self-update variants and every target embed the supplied key. Wrapper test fixtures set `BAML_TELEMETRY=off` when exercising uploaded release binaries. `toolchain use ` updates the closest existing `baml.toml`; `toolchain use --global ` (or `-g`) sets the user default in `$BAML_HOME/config.toml`. Output identifies the new selection and the previous selection, including recorded concrete channel versions. The project always wins over the user default, and `BAML_TOOLCHAIN` takes precedence over both. Relative project paths use the manifest directory; environment and user-default paths use the invocation directory. `toolchain pin` is removed. Updates follow the resolved shared channel and preserve selectors. Damaged installations require an explicit `toolchain install --force`. diff --git a/baml_language/wrapper/baml.toml b/baml_language/wrapper/baml.toml index eec5572a63c..cea3e0711b6 100644 --- a/baml_language/wrapper/baml.toml +++ b/baml_language/wrapper/baml.toml @@ -6,4 +6,6 @@ http_bodies = false on_initial_telemetry_failure = "ignore" [pack.env_var_names] -BAML_TELEMETRY = "BAML_WRAPPER_BCS_TELEMETRY" +BAML_TELEMETRY = "BAML_TELEMETRY" +BOUNDARY_API_KEY = "BOUNDARY_API_KEY" +BOUNDARY_PROJECT = "BOUNDARY_PROJECT" diff --git a/baml_language/wrapper/build b/baml_language/wrapper/build index 7e89be8e6e7..4a90be40c5b 100755 --- a/baml_language/wrapper/build +++ b/baml_language/wrapper/build @@ -20,16 +20,10 @@ parser.add_argument("--version") parser.add_argument("--no-self-update", action="store_true") parser.add_argument( "--embed-telemetry", - action="store_true", - help="mint and embed a public BCS ingestion credential", -) -parser.add_argument( - "--telemetry-environment", - help="existing BCS environment for the embedded credential", + metavar="ENV_VAR", + help="embed the named environment variable's value as the BCS telemetry API key", ) args = parser.parse_args() -if args.telemetry_environment and not args.embed_telemetry: - parser.error("--telemetry-environment requires --embed-telemetry") # Match a Cargo package version declaration, e.g. version = "0.2.5". version = ( args.version @@ -73,9 +67,7 @@ with tempfile.TemporaryDirectory(prefix="baml-wrapper-build-") as temporary: str(args.output.resolve()), ] if args.embed_telemetry: - command.append("--embed-telemetry") - if args.telemetry_environment: - command.extend(["--telemetry-environment", args.telemetry_environment]) + command.extend(["--embed-telemetry", args.embed_telemetry]) if args.target: command.extend(["--target", args.target]) if args.pack_host: diff --git a/baml_language/wrapper_tests/baml_src/telemetry.baml b/baml_language/wrapper_tests/baml_src/telemetry.baml index aa95d3a5ab2..e6b8e88e267 100644 --- a/baml_language/wrapper_tests/baml_src/telemetry.baml +++ b/baml_language/wrapper_tests/baml_src/telemetry.baml @@ -135,7 +135,7 @@ testset "wrapper telemetry" { defer { fixture.close(); } fixture.env["DO_NOT_TRACK"] = "0"; fixture.env["BAML_WRAPPER_TELEMETRY_HOST"] = fixture.env["BAML_MANIFEST_BASE_URL"]; - fixture.env["BAML_WRAPPER_BCS_TELEMETRY"] = "on"; + fixture.env["BAML_TELEMETRY"] = "on"; fixture.env["BOUNDARY_API_URL"] = fixture.env["BAML_MANIFEST_BASE_URL"]; fixture.env["BOUNDARY_API_KEY"] = "bdry_secret_fixture"; fixture.env["BOUNDARY_PROJECT"] = "fixture/project"; diff --git a/baml_language/wrapper_tests/fixture.py b/baml_language/wrapper_tests/fixture.py index 3fd53f26f61..b26aadd5a07 100644 --- a/baml_language/wrapper_tests/fixture.py +++ b/baml_language/wrapper_tests/fixture.py @@ -122,7 +122,7 @@ def init(): BAML_HOME=str(root / "baml"), WRAPPER_FIXTURE_ROOT=str(root), BAML_TELEMETRY_ENABLED="false", - BAML_WRAPPER_BCS_TELEMETRY="off", + BAML_TELEMETRY="off", DO_NOT_TRACK="1", ) return { diff --git a/typescript2/app-website/blog-releases/2026-10-08-baml-0.21.0.md b/typescript2/app-website/blog-releases/2026-10-08-baml-0.21.0.md index 5d015f6ca13..fe0f3dfa49d 100644 --- a/typescript2/app-website/blog-releases/2026-10-08-baml-0.21.0.md +++ b/typescript2/app-website/blog-releases/2026-10-08-baml-0.21.0.md @@ -172,11 +172,11 @@ baml query --local 'SELECT * FROM spans LIMIT 10' ## Embed a publisher-owned telemetry credential -Opt in while packing an executable or generating an SDK. The project and environment must already exist. `[pack].telemetry_environment` and `[bridge].telemetry_environment` can supply the environment default; configuring an environment alone does not mint a credential. +Opt in while packing an executable or generating an SDK by naming an environment variable that holds the telemetry API key. BAML embeds its value directly in the artifact, without a token exchange or build-time cloud request. The variable must be set to a non-empty value. ```sh -baml pack main --embed-telemetry --telemetry-environment=staging -baml generate --embed-telemetry --telemetry-environment=staging +baml pack main --embed-telemetry BAML_WRAPPER_BOUNDARY_API_KEY +baml generate --embed-telemetry BAML_WRAPPER_BOUNDARY_API_KEY ``` An embedded credential lets the publisher control the recording destination. Initial cloud failure aborts caller-configured cloud execution by default. Newly built artifacts with embedded telemetry default to warning and continuing. An explicit failure policy takes precedence.