From 09a898e31c92c88bb1b758063e3a8e872bcd54ad Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Wed, 12 Aug 2026 18:12:49 -0400 Subject: [PATCH 01/10] Simplify Compose control-plane authentication --- bb-cli/Cargo.lock | 33 - bb-cli/Cargo.toml | 1 - bb-cli/src/bb/apps.rs | 1002 +++++++------------- bb-cli/src/bb/auth_login.rs | 85 +- bb-cli/src/bb/auth_storage.rs | 4 +- bb-cli/src/bb/skills.rs | 12 +- bb-cli/tests/bb_e2e.rs | 645 ++----------- crates/builderbot-auth/src/auth_login.rs | 33 +- crates/builderbot-auth/src/auth_storage.rs | 337 +------ 9 files changed, 488 insertions(+), 1664 deletions(-) diff --git a/bb-cli/Cargo.lock b/bb-cli/Cargo.lock index 5995cd54c..5eb401f81 100644 --- a/bb-cli/Cargo.lock +++ b/bb-cli/Cargo.lock @@ -468,16 +468,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -1746,7 +1736,6 @@ dependencies = [ "builderbot-auth", "clap", "clap_complete", - "fs2", "pbjson", "pbjson-build", "pbjson-types", @@ -2298,22 +2287,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -2323,12 +2296,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/bb-cli/Cargo.toml b/bb-cli/Cargo.toml index a52a31321..ca433e4c9 100644 --- a/bb-cli/Cargo.toml +++ b/bb-cli/Cargo.toml @@ -19,7 +19,6 @@ anyhow = "1" builderbot-auth = { path = "../crates/builderbot-auth", features = ["blocking-client"] } clap = { version = "4.5", features = ["env"] } clap_complete = "4.5" -fs2 = "0.4" pbjson = "0.9" pbjson-types = "0.9" prost = "0.14" diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index bee70dd4a..cdbab5c17 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -6,55 +6,41 @@ //! `bb tools appkit`, and it does not migrate the separate internal Compose //! workflow. Both internal paths remain unchanged. //! -//! The CLI exchanges its stored bbidentity session for a short-lived -//! Compose-purpose bearer token. Public ingress validates that token online -//! through kgoose `ext_authz`, removes it, and forwards only verified identity -//! headers to Compose. Compose never receives the bearer token. +//! The CLI sends its stored bbidentity session only to the allowlisted Compose +//! control-plane origins. Public ingress authorizes that session through kgoose +//! `ext_authz` and removes it before forwarding the request internally. Compose +//! never receives the session credential. -use std::fs::{self, File, OpenOptions}; +use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; -use std::sync::Mutex; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use anyhow::{Context, Result}; -use builderbot_auth::auth_login::{auth_url, build_auth_http_client, playpen_baggage}; +use builderbot_auth::auth_login::{auth_url, build_auth_http_client}; use clap::{Arg, ArgMatches, Command}; -use fs2::FileExt; use reqwest::blocking::{multipart, Client, RequestBuilder, Response}; use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{json, Map, Value}; -use sha2::{Digest, Sha256}; -use super::auth::SESSION_CREDENTIAL_HEADER; -use super::auth_storage::{ - default_session_storage, session_storage_key_from_config, PurposeTokenStorageKey, - SessionCredentialStorage, StoredPurposeTokenCredential, -}; +use super::auth_login::ensure_browser_login; +use super::auth_storage::{default_session_storage, session_storage_key_from_config}; use super::display::{print_json, terminal_safe_text, Style}; use super::runner; use super::skills_api::{exit_codes, failure}; -use super::skills_config::{kgoose_service_url, SkillsConfig}; +use super::skills_config::SkillsConfig; const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; -const COMPOSE_TOKEN_EXCHANGE_PATH: &str = "/v1/auth/token/compose"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; -const TOKEN_EXCHANGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); // Compose may synchronously wait up to two minutes for an initialize or -// deploy rollout. Leave enough headroom for the response to traverse ingress -// without weakening the tighter credential-exchange bound above. +// deploy rollout. Leave enough headroom for the response to traverse ingress. const CONTROL_PLANE_REQUEST_TIMEOUT: Duration = Duration::from_secs(3 * 60); -const TOKEN_EXCHANGE_RESPONSE_MAX_BYTES: usize = 32 * 1024; const CONTROL_PLANE_RESPONSE_MAX_BYTES: usize = 2 * 1024 * 1024; -const COMPOSE_TOKEN_PURPOSE: &str = "compose"; -const PURPOSE_TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(60); -const PURPOSE_TOKEN_REPLACEMENT_INTERVAL: Duration = Duration::from_secs(60); -const PURPOSE_TOKEN_LOCK_FILE: &str = "apps-purpose-token.lock"; const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ "compose-ctrl.test.blockstaging.build", "compose-ctrl.app.builderlab.xyz", @@ -206,13 +192,13 @@ fn run_contract(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { .context("expected Apps Platform client version")?; let client = ControlPlaneClient::new(base_url, client_version, config.style)?; - let token_provider = KgoosePurposeTokenProvider::from_config(config)?; - let contract = client.contract(&token_provider)?; + let credential = ComposeSessionCredential::from_config(config)?; + let contract = client.contract(&credential)?; print_json(&contract) } fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let (client, token_provider) = control_plane_context(config, matches)?; + let (client, credential) = control_plane_context(config, matches)?; let request = PlanRequest { app_id: matches.get_one::("app-id").map(String::as_str), name: matches.get_one::("name").map(String::as_str), @@ -223,7 +209,7 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { persistence: matches.get_one::("persistence").map(String::as_str), client_version: client.client_version_text(), }; - let plan = client.plan(&token_provider, &request)?; + let plan = client.plan(&credential, &request)?; let app_id = required_response_string(&plan, "app_id", "Apps Platform plan")?.to_string(); let initialize_required = plan .pointer("/initialize/required") @@ -240,7 +226,7 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { initialize_required.unwrap_or(false) || initialize_recommended.unwrap_or(false); let initialize = if should_initialize { let request = initialize_request_from_plan(&plan); - Some(client.initialize(&token_provider, &app_id, &request)?) + Some(client.initialize(&credential, &app_id, &request)?) } else { None }; @@ -280,15 +266,15 @@ fn run_deploy(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { version_id: matches.get_one::("version-id").cloned(), deployment_id: matches.get_one::("deployment-id").cloned(), }; - let (client, token_provider) = control_plane_context(config, matches)?; - let response = client.deploy(&token_provider, app_id, artifact, &options)?; + let (client, credential) = control_plane_context(config, matches)?; + let response = client.deploy(&credential, app_id, artifact, &options)?; print_json(&response) } fn control_plane_context( config: &SkillsConfig, matches: &ArgMatches, -) -> Result<(ControlPlaneClient, KgoosePurposeTokenProvider)> { +) -> Result<(ControlPlaneClient, ComposeSessionCredential)> { let base_url = matches .get_one::("apps-base-url") .context("expected Apps Platform control-plane URL")?; @@ -296,8 +282,8 @@ fn control_plane_context( .get_one::("apps-client-version") .context("expected Apps Platform client version")?; let client = ControlPlaneClient::new(base_url, client_version, config.style)?; - let token_provider = KgoosePurposeTokenProvider::from_config(config)?; - Ok((client, token_provider)) + let credential = ComposeSessionCredential::from_config(config)?; + Ok((client, credential)) } #[derive(Serialize)] @@ -363,263 +349,60 @@ fn validate_artifact_path(path: &Path) -> Result<()> { Ok(()) } -/// Supplies the short-lived Compose bearer accepted by public ingress. Keeping -/// exchange and request construction separate makes the credential lifecycle -/// independently testable. The provider shares a session-bound token across -/// CLI processes so kgoose's one-active-token contract is respected. -trait ComposeCredentialProvider { - fn authorization_header(&self) -> Result; - - /// Returns a replacement credential after ingress rejects `rejected`, or - /// `None` when retrying cannot help yet. Implementations must not return - /// the rejected value, which keeps the control-plane retry bounded. - fn authorization_header_after_rejection( - &self, - _rejected: &HeaderValue, - ) -> Result> { - Ok(None) - } -} - -struct KgoosePurposeTokenProvider { - client: Client, - exchange_url: url::Url, - session_credential: HeaderValue, - session_credential_sha256: String, - baggage: Option, - style: Style, - storage: Box, - storage_key: PurposeTokenStorageKey, - refresh_lock_path: PathBuf, - refresh_mutex: Mutex<()>, +struct ComposeSessionCredential { + authorization: HeaderValue, + secret: String, } -impl KgoosePurposeTokenProvider { +impl ComposeSessionCredential { fn from_config(config: &SkillsConfig) -> Result { let storage = default_session_storage(config)?; let session_storage_key = session_storage_key_from_config(config); + Self::after_login(storage.as_ref(), &session_storage_key, || { + ensure_browser_login(config, storage.as_ref()) + }) + } + + fn after_login( + storage: &dyn super::auth_storage::SessionCredentialStorage, + session_storage_key: &super::auth_storage::SessionStorageKey, + login: F, + ) -> Result + where + F: FnOnce() -> Result<()>, + { + login()?; let credential = storage - .get(&session_storage_key)? + .get(session_storage_key)? .ok_or_else(auth_required_error)?; - let session_credential = credential + let secret = credential .session_credential_header_value() .ok_or_else(auth_required_error)?; - let session_credential_sha256 = sha256(&session_credential); - let session_credential = HeaderValue::from_str(&session_credential) - .context("stored BuilderBot CLI auth session is invalid; run `bb auth login`")?; - let server_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); - let exchange_url = auth_url(&server_url, COMPOSE_TOKEN_EXCHANGE_PATH) - .context("build Compose credential exchange URL")?; - let baggage = playpen_baggage(config.playpen.as_deref()) - .map(|value| HeaderValue::from_str(&value).context("build kgoose playpen header")) - .transpose()?; - - Ok(Self { - client: build_auth_http_client(TOKEN_EXCHANGE_REQUEST_TIMEOUT)?, - exchange_url, - session_credential, - session_credential_sha256, - baggage, - style: config.style, - storage, - storage_key: PurposeTokenStorageKey::new(&session_storage_key, COMPOSE_TOKEN_PURPOSE), - refresh_lock_path: config.bb_home.join(PURPOSE_TOKEN_LOCK_FILE), - refresh_mutex: Mutex::new(()), - }) - } - - fn exchange_purpose_token(&self) -> Result { - self.style - .verbose(&format!("POST {COMPOSE_TOKEN_EXCHANGE_PATH}")); - let mut request = self - .client - .post(self.exchange_url.clone()) - .header(USER_AGENT, apps_user_agent()) - .header(ACCEPT, "application/json") - .header(SESSION_CREDENTIAL_HEADER, self.session_credential.clone()); - if let Some(baggage) = &self.baggage { - request = request.header("Baggage", baggage.clone()); - } - let response = request - .send() - .map_err(|error| network_failure("POST", COMPOSE_TOKEN_EXCHANGE_PATH, error))?; - let status = response.status(); - if status == StatusCode::TOO_MANY_REQUESTS { - self.style - .verbose(&format!("POST {COMPOSE_TOKEN_EXCHANGE_PATH} -> {status}")); - return Ok(PurposeTokenExchangeOutcome::RateLimited); - } - if !status.is_success() { - self.style - .verbose(&format!("POST {COMPOSE_TOKEN_EXCHANGE_PATH} -> {status}")); - return Err(exchange_http_failure(status)); - } - let body = read_limited_response_body( - response, - TOKEN_EXCHANGE_RESPONSE_MAX_BYTES, - "Compose credential exchange", - )?; - self.style.verbose(&format!( - "POST {COMPOSE_TOKEN_EXCHANGE_PATH} -> {status} ({} bytes)", - body.len() - )); - let exchange: PurposeTokenExchangeResponse = - serde_json::from_str(&body).context("parse Compose credential exchange response")?; - if exchange.token_type.as_deref() != Some("Bearer") { - anyhow::bail!("Compose credential exchange returned an unsupported token type"); - } - let access_token = exchange - .access_token - .filter(|token| !token.trim().is_empty()) - .context("Compose credential exchange returned no access token")?; - purpose_token_authorization_header(&access_token) - .context("Compose credential exchange returned an invalid access token")?; - let expires_in_seconds = exchange - .expires_in_seconds - .filter(|seconds| *seconds > 0) - .context("Compose credential exchange returned no positive expiry")?; - let issued_at_unix_seconds = unix_time_seconds()?; - let expires_at_unix_seconds = issued_at_unix_seconds - .checked_add(expires_in_seconds) - .context("Compose credential exchange returned an invalid expiry")?; - Ok(PurposeTokenExchangeOutcome::Issued( - StoredPurposeTokenCredential { - access_token, - token_type: "Bearer".to_string(), - issued_at_unix_seconds, - expires_at_unix_seconds, - session_credential_sha256: self.session_credential_sha256.clone(), - }, - )) + Self::new(secret) } - fn cached_authorization_header( - &self, - credential: &StoredPurposeTokenCredential, - ) -> Result> { - if credential.session_credential_sha256 != self.session_credential_sha256 - || credential.token_type != "Bearer" - || credential.access_token.trim().is_empty() - { - return Ok(None); - } - let refresh_after = unix_time_seconds()? - .checked_add(PURPOSE_TOKEN_REFRESH_SKEW.as_secs()) - .context("system time overflow while checking Compose credential")?; - if credential.expires_at_unix_seconds <= refresh_after { - return Ok(None); - } - purpose_token_authorization_header(&credential.access_token) - .map(Some) - .context("cached Compose credential is invalid") - } - - fn read_cached_authorization_header( - &self, - ) -> Result> { - let Some(credential) = self.storage.get_purpose_token(&self.storage_key)? else { - return Ok(None); - }; - let Some(header) = self.cached_authorization_header(&credential)? else { - return Ok(None); - }; - Ok(Some((credential, header))) - } - - fn refresh_lock(&self) -> Result { - if let Some(parent) = self.refresh_lock_path.parent() { - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - } - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&self.refresh_lock_path) - .with_context(|| format!("open {}", self.refresh_lock_path.display()))?; - #[cfg(unix)] + fn new(secret: String) -> Result { + if !(32..=512).contains(&secret.len()) + || !secret + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&self.refresh_lock_path, fs::Permissions::from_mode(0o600)) - .with_context(|| format!("chmod 600 {}", self.refresh_lock_path.display()))?; - } - FileExt::lock_exclusive(&file) - .with_context(|| format!("lock {}", self.refresh_lock_path.display()))?; - Ok(file) - } - - fn refresh_or_adopt(&self, rejected: Option<&HeaderValue>) -> Result> { - let _process_guard = self - .refresh_mutex - .lock() - .map_err(|_| anyhow::anyhow!("Compose credential cache lock was poisoned"))?; - let _file_guard = self.refresh_lock()?; - - let cached = self.read_cached_authorization_header()?; - if let Some((credential, header)) = cached { - if rejected.is_none() || rejected != Some(&header) { - return Ok(Some(header)); - } - - let replacement_allowed_at = credential - .issued_at_unix_seconds - .saturating_add(PURPOSE_TOKEN_REPLACEMENT_INTERVAL.as_secs()); - if unix_time_seconds()? < replacement_allowed_at { - return Ok(None); - } - } - - match self.exchange_purpose_token()? { - PurposeTokenExchangeOutcome::Issued(credential) => { - let header = self.cached_authorization_header(&credential)?.context( - "Compose credential exchange returned a credential too close to expiry", - )?; - self.storage - .set_purpose_token(&self.storage_key, &credential) - .context("store Compose purpose token")?; - if rejected == Some(&header) { - return Ok(None); - } - Ok(Some(header)) - } - PurposeTokenExchangeOutcome::RateLimited => { - if let Some((_credential, header)) = self.read_cached_authorization_header()? { - if rejected != Some(&header) { - return Ok(Some(header)); - } - } - Err(exchange_http_failure(StatusCode::TOO_MANY_REQUESTS)) - } + anyhow::bail!("stored BuilderBot CLI auth session is invalid; run `bb auth login`"); } + let authorization = HeaderValue::from_str(&format!("BBIdentity {secret}")) + .context("stored BuilderBot CLI auth session is invalid; run `bb auth login`")?; + Ok(Self { + authorization, + secret, + }) } -} - -#[derive(Deserialize)] -struct PurposeTokenExchangeResponse { - access_token: Option, - token_type: Option, - expires_in_seconds: Option, -} -enum PurposeTokenExchangeOutcome { - Issued(StoredPurposeTokenCredential), - RateLimited, -} - -impl ComposeCredentialProvider for KgoosePurposeTokenProvider { - fn authorization_header(&self) -> Result { - if let Some((_credential, header)) = self.read_cached_authorization_header()? { - return Ok(header); - } - self.refresh_or_adopt(None)? - .context("Compose credential exchange did not return a usable credential") + fn authorization_header(&self) -> HeaderValue { + self.authorization.clone() } - fn authorization_header_after_rejection( - &self, - rejected: &HeaderValue, - ) -> Result> { - self.refresh_or_adopt(Some(rejected)) + fn redact(&self, value: &str) -> String { + value.replace(&self.secret, "[REDACTED]") } } @@ -649,19 +432,20 @@ impl ControlPlaneClient { ) -> Result { let contract_url = auth_url(base_url, APPS_CONTRACT_PATH) .context("build Apps Platform control-plane contract URL")?; - if !matches!(contract_url.scheme(), "http" | "https") { - anyhow::bail!("Apps Platform control-plane URL must use http or https"); - } - if contract_url.scheme() == "http" && !is_loopback_url(&contract_url) { - anyhow::bail!( - "Apps Platform control-plane URL must use https unless it targets loopback local development" - ); - } if !is_trusted_control_plane_url(&contract_url) { anyhow::bail!( - "Apps Platform control-plane URL must target an approved Builderlab ingress host or loopback local development" + "Apps Platform control-plane URL must use HTTPS and target an approved Builderlab ingress host" ); } + Self::build(base_url, client_version, style, request_timeout) + } + + fn build( + base_url: &str, + client_version: &str, + style: Style, + request_timeout: Duration, + ) -> Result { let client_version_text = client_version.to_string(); let client_version = HeaderValue::from_str(client_version) .context("Apps Platform client version is not a valid HTTP header value")?; @@ -674,52 +458,52 @@ impl ControlPlaneClient { }) } + #[cfg(test)] + fn new_for_test( + base_url: &str, + client_version: &str, + style: Style, + request_timeout: Duration, + ) -> Result { + Self::build(base_url, client_version, style, request_timeout) + } + fn client_version_text(&self) -> &str { &self.client_version_text } - fn contract(&self, credential_provider: &dyn ComposeCredentialProvider) -> Result { + fn contract(&self, credential: &ComposeSessionCredential) -> Result { let url = self.endpoint(APPS_CONTRACT_PATH)?; - self.authorized_json_request( - credential_provider, - "GET", - APPS_CONTRACT_PATH, - |authorization| { - self.standard_request(self.client.get(url.clone()), authorization) - .send() - .map_err(|error| network_failure("GET", APPS_CONTRACT_PATH, error)) - }, - ) + self.authorized_json_request(credential, "GET", APPS_CONTRACT_PATH, |authorization| { + self.standard_request(self.client.get(url.clone()), authorization) + .send() + .map_err(|error| network_failure("GET", APPS_CONTRACT_PATH, error)) + }) } fn plan( &self, - credential_provider: &dyn ComposeCredentialProvider, + credential: &ComposeSessionCredential, request: &PlanRequest<'_>, ) -> Result { let url = self.endpoint(APPS_PLAN_PATH)?; - self.authorized_json_request( - credential_provider, - "POST", - APPS_PLAN_PATH, - |authorization| { - self.standard_request(self.client.post(url.clone()), authorization) - .json(request) - .send() - .map_err(|error| network_failure("POST", APPS_PLAN_PATH, error)) - }, - ) + self.authorized_json_request(credential, "POST", APPS_PLAN_PATH, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(request) + .send() + .map_err(|error| network_failure("POST", APPS_PLAN_PATH, error)) + }) } fn initialize( &self, - credential_provider: &dyn ComposeCredentialProvider, + credential: &ComposeSessionCredential, app_id: &str, request: &Value, ) -> Result { let url = self.app_action_url(app_id, "initialize")?; let path = url.path().to_string(); - self.authorized_json_request(credential_provider, "POST", &path, |authorization| { + self.authorized_json_request(credential, "POST", &path, |authorization| { self.standard_request(self.client.post(url.clone()), authorization) .json(request) .send() @@ -729,14 +513,14 @@ impl ControlPlaneClient { fn deploy( &self, - credential_provider: &dyn ComposeCredentialProvider, + credential: &ComposeSessionCredential, app_id: &str, artifact: &Path, options: &DeployOptions, ) -> Result { let url = self.app_action_url(app_id, "deploy")?; let path = url.path().to_string(); - self.authorized_json_request(credential_provider, "POST", &path, |authorization| { + self.authorized_json_request(credential, "POST", &path, |authorization| { let form = deploy_form(artifact, options)?; self.standard_request(self.client.post(url.clone()), authorization) .multipart(form) @@ -778,7 +562,7 @@ impl ControlPlaneClient { fn authorized_json_request( &self, - credential_provider: &dyn ComposeCredentialProvider, + credential: &ComposeSessionCredential, method: &str, path: &str, send: F, @@ -786,18 +570,12 @@ impl ControlPlaneClient { where F: Fn(HeaderValue) -> Result, { - let authorization = credential_provider.authorization_header()?; - let (mut status, mut body) = - self.request_response(method, path, &send, authorization.clone())?; - if status == StatusCode::UNAUTHORIZED { - if let Some(replacement) = - credential_provider.authorization_header_after_rejection(&authorization)? - { - (status, body) = self.request_response(method, path, &send, replacement)?; - } - } + let authorization = credential.authorization_header(); + let (status, body) = self.request_response(method, path, &send, authorization)?; if !status.is_success() { - return Err(control_plane_http_failure(method, path, status, &body)); + return Err(control_plane_http_failure( + method, path, status, &body, credential, + )); } serde_json::from_str(&body) .with_context(|| format!("parse Apps Platform {method} {path} response")) @@ -849,10 +627,11 @@ fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result bool { - if is_loopback_url(url) { - return true; - } - if url.scheme() != "https" || url.port_or_known_default() != Some(443) { + if url.scheme() != "https" + || url.port_or_known_default() != Some(443) + || !url.username().is_empty() + || url.password().is_some() + { return false; } let Some(url::Host::Domain(host)) = url.host() else { @@ -863,15 +642,6 @@ fn is_trusted_control_plane_url(url: &url::Url) -> bool { .any(|trusted| host.eq_ignore_ascii_case(trusted)) } -fn is_loopback_url(url: &url::Url) -> bool { - match url.host() { - Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - None => false, - } -} - fn read_limited_response_body( response: reqwest::blocking::Response, max_bytes: usize, @@ -888,25 +658,6 @@ fn read_limited_response_body( String::from_utf8(bytes).with_context(|| format!("decode {description} response as UTF-8")) } -fn purpose_token_authorization_header(access_token: &str) -> Result { - HeaderValue::from_str(&format!("Bearer {access_token}")) - .context("build Compose authorization header") -} - -fn unix_time_seconds() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before the Unix epoch") - .map(|duration| duration.as_secs()) -} - -fn sha256(value: &str) -> String { - Sha256::digest(value.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - fn apps_user_agent() -> String { format!("bb-apps/{}", env!("CARGO_PKG_VERSION")) } @@ -927,38 +678,12 @@ fn network_failure(method: &str, path: &str, error: reqwest::Error) -> anyhow::E ) } -fn exchange_http_failure(status: StatusCode) -> anyhow::Error { - let (exit_code, code, hint) = match status.as_u16() { - 401 => ( - exit_codes::AUTH_REQUIRED, - "auth_required", - "; run `bb auth login` to refresh your session", - ), - 403 => ( - exit_codes::FORBIDDEN, - "forbidden", - "; the current account does not have Builderlab access", - ), - 429 => ( - exit_codes::GENERAL, - "credential_exchange_rate_limited", - "; retry later", - ), - value if value >= 500 => (exit_codes::NETWORK, "credential_exchange_unavailable", ""), - _ => (exit_codes::GENERAL, "credential_exchange_failed", ""), - }; - failure( - exit_code, - code, - format!("Compose credential exchange failed with {status}{hint}"), - ) -} - fn control_plane_http_failure( method: &str, path: &str, status: StatusCode, body: &str, + credential: &ComposeSessionCredential, ) -> anyhow::Error { let parsed = serde_json::from_str::(body).ok(); let code = parsed @@ -966,18 +691,25 @@ fn control_plane_http_failure( .and_then(|value| value.pointer("/error/code")) .and_then(Value::as_str) .unwrap_or("control_plane_request_failed"); - let next_action = parsed - .as_ref() - .and_then(|value| { - value - .get("next_action") - .or_else(|| value.pointer("/error/next_action")) - }) - .and_then(Value::as_str); + let code = credential.redact(&terminal_safe_text(code)); + let next_action = if status == StatusCode::UNAUTHORIZED { + Some("Run `bb auth login` to refresh your session.".to_string()) + } else { + parsed + .as_ref() + .and_then(|value| { + value + .get("next_action") + .or_else(|| value.pointer("/error/next_action")) + }) + .and_then(Value::as_str) + .map(terminal_safe_text) + .map(|value| credential.redact(&value)) + }; let mut message = format!("{method} {path} failed with {status}"); if let Some(next_action) = next_action { message.push_str("\nnext_action: "); - message.push_str(&terminal_safe_text(next_action)); + message.push_str(&next_action); } let exit_code = match status.as_u16() { 401 => exit_codes::AUTH_REQUIRED, @@ -985,112 +717,90 @@ fn control_plane_http_failure( value if value >= 500 => exit_codes::NETWORK, _ => exit_codes::GENERAL, }; - failure(exit_code, code, message) + failure(exit_code, &code, message) } #[cfg(test)] mod tests { use std::thread; - use builderbot_auth::auth_storage::{FileSessionCredentialStorage, SessionStorageKey}; + use builderbot_auth::auth_storage::{ + InMemorySessionCredentialStorage, SessionCredentialStorage, SessionStorageKey, + StoredSessionCredential, + }; use tiny_http::{Header, Response, Server}; use super::*; - fn test_provider( - exchange_url: url::Url, - temporary_directory: &tempfile::TempDir, - ) -> KgoosePurposeTokenProvider { - let session_credential = "test-bbidentity-session"; - let session_storage_key = SessionStorageKey::new("test", exchange_url.as_str()); - KgoosePurposeTokenProvider { - client: build_auth_http_client(TOKEN_EXCHANGE_REQUEST_TIMEOUT) - .expect("build HTTP client"), - exchange_url, - session_credential: HeaderValue::from_static(session_credential), - session_credential_sha256: sha256(session_credential), - baggage: None, - style: Style::new(true, false, false), - storage: Box::new(FileSessionCredentialStorage::new( - temporary_directory.path().join("sessions.json"), - )), - storage_key: PurposeTokenStorageKey::new(&session_storage_key, COMPOSE_TOKEN_PURPOSE), - refresh_lock_path: temporary_directory.path().join(PURPOSE_TOKEN_LOCK_FILE), - refresh_mutex: Mutex::new(()), - } + fn test_credential(secret: &str) -> ComposeSessionCredential { + ComposeSessionCredential::new(secret.to_string()).expect("build test credential") } #[test] - fn purpose_token_provider_reuses_token_across_provider_instances() { - let temporary_directory = tempfile::tempdir().expect("create temporary directory"); - let server = Server::http("127.0.0.1:0").expect("bind token exchange server"); - let exchange_url = url::Url::parse(&format!( - "http://{}{COMPOSE_TOKEN_EXCHANGE_PATH}", - server.server_addr() - )) - .expect("parse exchange URL"); - let server_thread = thread::spawn(move || { - let request = server.recv().expect("receive token exchange"); - assert_eq!(request.method().as_str(), "POST"); - assert_eq!(request.url(), COMPOSE_TOKEN_EXCHANGE_PATH); - request - .respond( - Response::from_string( - r#"{"access_token":"cached-compose-token","token_type":"Bearer","expires_in_seconds":300}"#, - ) - .with_header( - Header::from_bytes("Content-Type", "application/json") - .expect("build content type"), - ), - ) - .expect("respond to token exchange"); - }); - let provider = test_provider(exchange_url.clone(), &temporary_directory); + fn compose_session_header_matches_kgoose_contract() { + let secret = "abcdefghijklmnopqrstuvwxyz_ABCDE-1234"; + let credential = test_credential(secret); + + assert_eq!( + credential + .authorization_header() + .to_str() + .expect("authorization text"), + format!("BBIdentity {secret}") + ); + for invalid in [ + "too-short", + "credential with spaces that is long enough", + "credential.with.punctuation.that.is.long", + ] { + let error = ComposeSessionCredential::new(invalid.to_string()) + .err() + .expect("reject invalid session credential"); + assert!(!error.to_string().contains(invalid)); + } + } - let first = provider - .authorization_header() - .expect("first authorization header"); - drop(provider); - let second = test_provider(exchange_url, &temporary_directory) - .authorization_header() - .expect("persisted authorization header"); + #[test] + fn compose_session_continues_with_the_session_stored_by_login() { + let storage = InMemorySessionCredentialStorage::default(); + let storage_key = SessionStorageKey::new("default", "https://kgoose.example"); + let secret = "session_stored_after_browser_login_12345"; + + let credential = ComposeSessionCredential::after_login(&storage, &storage_key, || { + storage.set( + &storage_key, + &StoredSessionCredential { + session_credential: secret.to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), + }, + ) + }) + .expect("continue after login"); assert_eq!( - first, - HeaderValue::from_static("Bearer cached-compose-token") + credential + .authorization_header() + .to_str() + .expect("authorization text"), + format!("BBIdentity {secret}") ); - assert_eq!(second, first); - server_thread.join().expect("join token exchange server"); } #[test] - fn control_plane_restricts_token_recipients() { + fn control_plane_allowlist_is_exact_and_https_only() { let style = Style::new(true, false, false); - assert!(ControlPlaneClient::new( + for trusted in [ "https://compose-ctrl.test.blockstaging.build", - "1.0.0", - style - ) - .is_ok()); - assert!( - ControlPlaneClient::new("https://compose-ctrl.app.builderlab.xyz", "1.0.0", style,) - .is_ok() - ); - assert!(ControlPlaneClient::new("http://localhost:8080", "1.0.0", style).is_ok()); - assert!(ControlPlaneClient::new("http://127.0.0.1:8080", "1.0.0", style).is_ok()); - assert!(ControlPlaneClient::new("http://[::1]:8080", "1.0.0", style).is_ok()); - - let error = ControlPlaneClient::new( - "http://compose-ctrl.test.blockstaging.build", - "1.0.0", - style, - ) - .err() - .expect("reject cleartext external URL"); - assert!(error.to_string().contains("must use https")); + "https://compose-ctrl.app.builderlab.xyz", + "https://compose-ctrl.test.blockstaging.build:443", + ] { + ControlPlaneClient::new(trusted, "1.0.0", style) + .unwrap_or_else(|error| panic!("trusted URL {trusted} was rejected: {error}")); + } for untrusted in [ + "http://compose-ctrl.test.blockstaging.build", "https://attacker.example", "https://test.blockstaging.build", "https://app.builderlab.xyz", @@ -1098,8 +808,13 @@ mod tests { "https://compose-ctrl.test.blockstaging.build:444", "https://compose-ctrl.app.builderlab.xyz.attacker.example", "https://compose-ctrl.app.builderlab.xyz:444", - "https://test.blockstaging.build.attacker.example", - "https://test.blockstaging.build:444", + "https://user@compose-ctrl.test.blockstaging.build", + "http://localhost:8080", + "https://localhost:8080", + "http://127.0.0.1:8080", + "https://127.0.0.1:8443", + "http://[::1]:8080", + "https://[::1]:8443", ] { let error = ControlPlaneClient::new(untrusted, "1.0.0", style) .err() @@ -1109,198 +824,164 @@ mod tests { } #[test] - fn cached_token_requires_current_session_and_safe_expiry() { - let temporary_directory = tempfile::tempdir().expect("create temporary directory"); - let provider = test_provider( - url::Url::parse("http://127.0.0.1:9/v1/auth/token/compose") - .expect("parse exchange URL"), - &temporary_directory, - ); - let now = unix_time_seconds().expect("read system time"); - let mut credential = StoredPurposeTokenCredential { - access_token: "cached-token".to_string(), - token_type: "Bearer".to_string(), - issued_at_unix_seconds: now, - expires_at_unix_seconds: now + 300, - session_credential_sha256: "different-session".to_string(), - }; - - assert!(provider - .cached_authorization_header(&credential) - .expect("validate different session") - .is_none()); - - credential.session_credential_sha256 = provider.session_credential_sha256.clone(); - credential.expires_at_unix_seconds = now + PURPOSE_TOKEN_REFRESH_SKEW.as_secs(); - assert!(provider - .cached_authorization_header(&credential) - .expect("validate near-expiry token") - .is_none()); - - credential.expires_at_unix_seconds = now + 300; - assert_eq!( - provider - .cached_authorization_header(&credential) - .expect("validate reusable token"), - Some(HeaderValue::from_static("Bearer cached-token")) - ); - } - - #[test] - fn control_plane_retries_once_with_rotated_cached_token() { - struct RotatingCredentialProvider; - - impl ComposeCredentialProvider for RotatingCredentialProvider { - fn authorization_header(&self) -> Result { - Ok(HeaderValue::from_static("Bearer rejected-token")) - } - - fn authorization_header_after_rejection( - &self, - rejected: &HeaderValue, - ) -> Result> { - assert_eq!(rejected, HeaderValue::from_static("Bearer rejected-token")); - Ok(Some(HeaderValue::from_static("Bearer rotated-token"))) - } - } - + fn control_plane_uses_bbidentity_authorization_without_identity_headers() { + let secret = "opaque_session_credential_1234567890"; let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); let server_thread = thread::spawn(move || { - let first = server.recv().expect("receive first contract request"); + let request = server.recv().expect("receive contract request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), APPS_CONTRACT_PATH); assert_eq!( - first + request .headers() .iter() .find(|header| header.field.equiv("Authorization")) .map(|header| header.value.as_str()), - Some("Bearer rejected-token") + Some("BBIdentity opaque_session_credential_1234567890") ); - first - .respond(Response::from_string("unauthorized").with_status_code(401)) - .expect("reject first contract request"); - - let second = server.recv().expect("receive retried contract request"); - assert_eq!( - second + for forbidden in [ + "X-BB-Session-Credential", + "X-Forwarded-User", + "X-Forwarded-Workspace-Id", + ] { + assert!(!request .headers() .iter() - .find(|header| header.field.equiv("Authorization")) - .map(|header| header.value.as_str()), - Some("Bearer rotated-token") - ); - second + .any(|header| header.field.equiv(forbidden))); + } + request .respond( Response::from_string(r#"{"contract_version":"test"}"#).with_header( Header::from_bytes("Content-Type", "application/json") .expect("build content type"), ), ) - .expect("respond to retried contract request"); + .expect("respond to contract request"); }); - let client = ControlPlaneClient::new(&base_url, "1.0.0", Style::new(true, false, false)) - .expect("build control-plane client"); + let client = ControlPlaneClient::new_for_test( + &base_url, + "1.0.0", + Style::new(true, false, false), + Duration::from_secs(2), + ) + .expect("build test control-plane client"); let contract = client - .contract(&RotatingCredentialProvider) - .expect("retry contract request"); + .contract(&test_credential(secret)) + .expect("read contract"); assert_eq!(contract["contract_version"], "test"); - server_thread.join().expect("join control-plane server"); + server_thread.join().expect("join request server"); } #[test] - fn deploy_retries_once_and_reopens_the_artifact() { - struct RotatingCredentialProvider; + fn control_plane_does_not_follow_redirects_or_forward_the_session() { + let target = Server::http("127.0.0.1:0").expect("bind redirect target"); + let target_url = format!("http://{}/stolen", target.server_addr()); + let redirector = Server::http("127.0.0.1:0").expect("bind redirector"); + let base_url = format!("http://{}", redirector.server_addr()); + let redirect_thread = thread::spawn(move || { + let request = redirector.recv().expect("receive original request"); + request + .respond(Response::empty(302).with_header( + Header::from_bytes("Location", target_url).expect("build redirect header"), + )) + .expect("send redirect"); + }); + let client = ControlPlaneClient::new_for_test( + &base_url, + "1.0.0", + Style::new(true, false, false), + Duration::from_secs(2), + ) + .expect("build test control-plane client"); + let secret = "redirect_session_credential_123456"; - impl ComposeCredentialProvider for RotatingCredentialProvider { - fn authorization_header(&self) -> Result { - Ok(HeaderValue::from_static("Bearer rejected-token")) - } + let error = client + .contract(&test_credential(secret)) + .expect_err("reject redirect response"); + + assert!(error.to_string().contains("302")); + assert!(!error.to_string().contains(secret)); + assert!(target + .recv_timeout(Duration::from_millis(250)) + .expect("wait for redirect target") + .is_none()); + redirect_thread.join().expect("join redirect server"); + } - fn authorization_header_after_rejection( - &self, - rejected: &HeaderValue, - ) -> Result> { - assert_eq!(rejected, HeaderValue::from_static("Bearer rejected-token")); - Ok(Some(HeaderValue::from_static("Bearer rotated-token"))) - } - } + #[test] + fn expired_session_is_not_retried_and_returns_login_guidance() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive expired session request"); + request + .respond(Response::from_string("expired").with_status_code(401)) + .expect("reject expired session"); + assert!(server + .recv_timeout(Duration::from_millis(250)) + .expect("wait for unexpected retry") + .is_none()); + }); + let client = ControlPlaneClient::new_for_test( + &base_url, + "1.0.0", + Style::new(true, false, false), + Duration::from_secs(2), + ) + .expect("build test control-plane client"); + let secret = "expired_session_credential_1234567"; - let temporary_directory = tempfile::tempdir().expect("create temporary directory"); - let artifact_path = temporary_directory.path().join("artifact.tar.gz"); - fs::write(&artifact_path, b"retryable-artifact-marker").expect("write artifact"); + let error = client + .contract(&test_credential(secret)) + .expect_err("reject expired session"); + let message = error.to_string(); + + assert!(message.contains("401")); + assert!(message.contains("bb auth login")); + assert!(!message.contains(secret)); + server_thread.join().expect("join request server"); + } + + #[test] + fn control_plane_errors_redact_the_session_credential() { let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); + let secret = "reflected_session_credential_123456"; + let response_body = json!({ + "error": {"code": secret}, + "next_action": format!("remove {secret} from the request") + }) + .to_string(); let server_thread = thread::spawn(move || { - for (index, expected_token) in ["Bearer rejected-token", "Bearer rotated-token"] - .into_iter() - .enumerate() - { - let mut request = server.recv().expect("receive deploy request"); - assert_eq!(request.method().as_str(), "POST"); - assert_eq!(request.url(), "/v1/agent/apps/retry-app/deploy"); - assert_eq!( - request - .headers() - .iter() - .find(|header| header.field.equiv("Authorization")) - .map(|header| header.value.as_str()), - Some(expected_token) - ); - let mut body = Vec::new(); - request - .as_reader() - .read_to_end(&mut body) - .expect("read deploy body"); - assert!(body - .windows(b"retryable-artifact-marker".len()) - .any(|window| window == b"retryable-artifact-marker")); - if index == 0 { - request - .respond(Response::from_string("unauthorized").with_status_code(401)) - .expect("reject first deploy request"); - } else { - request - .respond( - Response::from_string(r#"{"ok":true,"version_id":"ver-test"}"#) - .with_header( - Header::from_bytes("Content-Type", "application/json") - .expect("build content type"), - ), - ) - .expect("respond to retried deploy request"); - } - } + let request = server.recv().expect("receive request"); + request + .respond(Response::from_string(response_body).with_status_code(400)) + .expect("send reflected error"); }); - let client = ControlPlaneClient::new(&base_url, "1.0.0", Style::new(true, false, false)) - .expect("build control-plane client"); + let client = ControlPlaneClient::new_for_test( + &base_url, + "1.0.0", + Style::new(true, false, false), + Duration::from_secs(2), + ) + .expect("build test control-plane client"); - let response = client - .deploy( - &RotatingCredentialProvider, - "retry-app", - &artifact_path, - &DeployOptions::default(), - ) - .expect("retry deploy request"); + let error = client + .contract(&test_credential(secret)) + .expect_err("reject failed request"); + let message = format!("{error:#}"); - assert_eq!(response["version_id"], "ver-test"); - server_thread.join().expect("join control-plane server"); + assert!(!message.contains(secret)); + assert!(message.contains("[REDACTED]")); + server_thread.join().expect("join request server"); } #[test] fn initialize_and_deploy_allow_delayed_rollout_responses() { - struct StaticCredentialProvider; - - impl ComposeCredentialProvider for StaticCredentialProvider { - fn authorization_header(&self) -> Result { - Ok(HeaderValue::from_static("Bearer test-token")) - } - } - assert!(CONTROL_PLANE_REQUEST_TIMEOUT > Duration::from_secs(2 * 60)); - assert_eq!(TOKEN_EXCHANGE_REQUEST_TIMEOUT, Duration::from_secs(30)); let temporary_directory = tempfile::tempdir().expect("create temporary directory"); let artifact_path = temporary_directory.path().join("artifact.tar.gz"); @@ -1343,7 +1024,7 @@ mod tests { ) .expect("respond to deploy request"); }); - let client = ControlPlaneClient::new_with_timeout( + let client = ControlPlaneClient::new_for_test( &base_url, "1.0.0", Style::new(true, false, false), @@ -1353,14 +1034,14 @@ mod tests { let initialized = client .initialize( - &StaticCredentialProvider, + &test_credential("delayed_session_credential_123456"), "delayed-app", &json!({"environment": "staging"}), ) .expect("wait for delayed initialize response"); let deployed = client .deploy( - &StaticCredentialProvider, + &test_credential("delayed_session_credential_123456"), "delayed-app", &artifact_path, &DeployOptions::default(), @@ -1374,14 +1055,6 @@ mod tests { #[test] fn control_plane_bounds_plan_responses() { - struct StaticCredentialProvider; - - impl ComposeCredentialProvider for StaticCredentialProvider { - fn authorization_header(&self) -> Result { - Ok(HeaderValue::from_static("Bearer test-token")) - } - } - let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); let server_thread = thread::spawn(move || { @@ -1394,8 +1067,13 @@ mod tests { ])) .expect("respond with oversized plan response"); }); - let client = ControlPlaneClient::new(&base_url, "1.0.0", Style::new(true, false, false)) - .expect("build control-plane client"); + let client = ControlPlaneClient::new_for_test( + &base_url, + "1.0.0", + Style::new(true, false, false), + Duration::from_secs(2), + ) + .expect("build test control-plane client"); let request = PlanRequest { app_id: Some("bounded-app"), name: None, @@ -1406,20 +1084,26 @@ mod tests { }; let error = client - .plan(&StaticCredentialProvider, &request) + .plan( + &test_credential("bounded_session_credential_123456"), + &request, + ) .expect_err("reject oversized plan response"); assert!(error.to_string().contains("exceeded 2097152 bytes")); - assert!(!error.to_string().contains("test-token")); + assert!(!error + .to_string() + .contains("bounded_session_credential_123456")); server_thread.join().expect("join control-plane server"); } #[test] fn app_ids_are_encoded_as_single_path_segments() { - let client = ControlPlaneClient::new( + let client = ControlPlaneClient::new_for_test( "http://127.0.0.1:9", "1.0.0", Style::new(true, false, false), + Duration::from_secs(2), ) .expect("build control-plane client"); @@ -1429,34 +1113,4 @@ mod tests { assert_eq!(url.path(), "/v1/agent/apps/app%2F..%2F..%2Fidentity/deploy"); } - - #[test] - fn purpose_token_provider_rejects_oversized_exchange_response() { - let temporary_directory = tempfile::tempdir().expect("create temporary directory"); - let server = Server::http("127.0.0.1:0").expect("bind token exchange server"); - let exchange_url = url::Url::parse(&format!( - "http://{}{COMPOSE_TOKEN_EXCHANGE_PATH}", - server.server_addr() - )) - .expect("parse exchange URL"); - let server_thread = thread::spawn(move || { - let request = server.recv().expect("receive token exchange"); - request - .respond(Response::from_data(vec![ - b'x'; - TOKEN_EXCHANGE_RESPONSE_MAX_BYTES - + 1 - ])) - .expect("respond to token exchange"); - }); - let provider = test_provider(exchange_url, &temporary_directory); - - let error = provider - .authorization_header() - .expect_err("reject oversized exchange response"); - - assert!(error.to_string().contains("exceeded 32768 bytes")); - assert!(!error.to_string().contains("test-bbidentity-session")); - server_thread.join().expect("join token exchange server"); - } } diff --git a/bb-cli/src/bb/auth_login.rs b/bb-cli/src/bb/auth_login.rs index a385cf291..59c4d853b 100644 --- a/bb-cli/src/bb/auth_login.rs +++ b/bb-cli/src/bb/auth_login.rs @@ -49,6 +49,68 @@ pub enum BrowserLoginCredentialSource { pub fn run_browser_login( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, +) -> Result { + run_browser_login_with_output(config, storage, BrowserLoginOutput::Standalone) +} + +pub fn ensure_browser_login( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, +) -> Result<()> { + run_browser_login_with_output(config, storage, BrowserLoginOutput::Embedded).map(|_| ()) +} + +#[derive(Clone, Copy)] +enum BrowserLoginOutput { + Standalone, + Embedded, +} + +impl BrowserLoginOutput { + fn info(self, config: &SkillsConfig, message: &str) { + match self { + Self::Standalone => auth_info(config, message), + Self::Embedded => config.style.verbose(message), + } + } + + fn login_url(self, config: &SkillsConfig, login_url: &Url) { + if config.json { + return; + } + match self { + Self::Standalone => { + println!("Opening BuilderBot auth login in your browser:"); + println!("{login_url}"); + } + Self::Embedded => { + eprintln!("Opening BuilderBot auth login in your browser:"); + eprintln!("{login_url}"); + } + } + } + + fn browser_fallback(self) { + match self { + Self::Standalone => { + println!("Could not open a browser automatically. Open the URL above manually.") + } + Self::Embedded => { + eprintln!("Could not open a browser automatically. Open the URL above manually.") + } + } + } + + #[cfg(test)] + fn writes_command_stdout(self) -> bool { + matches!(self, Self::Standalone) + } +} + +fn run_browser_login_with_output( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, + output: BrowserLoginOutput, ) -> Result { let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); let client = build_auth_http_client(Duration::from_secs(30))?; @@ -62,7 +124,7 @@ pub fn run_browser_login( &service_url, &stored, )? { - auth_info( + output.info( config, &format!( "Found valid BuilderBot CLI auth session in {} storage", @@ -81,7 +143,7 @@ pub fn run_browser_login( credential_sha256_prefix: None, }); } - auth_info( + output.info( config, &format!( "Stored BuilderBot CLI auth session in {} storage is invalid", @@ -90,7 +152,7 @@ pub fn run_browser_login( ); } None => { - auth_info( + output.info( config, &format!( "No BuilderBot CLI auth session found in {} storage", @@ -111,17 +173,14 @@ pub fn run_browser_login( let _ = tx.send(result); }); - if !config.json { - println!("Opening BuilderBot auth login in your browser:"); - println!("{login_url}"); - } + output.login_url(config, &login_url); if let Err(error) = webbrowser::open(login_url.as_str()) { if config.json { return Err(anyhow!( "failed to open browser for BuilderBot auth login: {error}" )); } - println!("Could not open a browser automatically. Open the URL above manually."); + output.browser_fallback(); } let code = rx @@ -133,7 +192,7 @@ pub fn run_browser_login( let me = verified.me; let workspace_name = me.active_workspace_name()?.to_string(); storage.set(&storage_key, &stored)?; - auth_info( + output.info( config, &format!( "Stored BuilderBot CLI auth session in {} storage", @@ -308,7 +367,13 @@ fn auth_info(config: &SkillsConfig, message: &str) { #[cfg(test)] mod tests { - use super::{auth_callback_page, AuthCallbackPage}; + use super::{auth_callback_page, AuthCallbackPage, BrowserLoginOutput}; + + #[test] + fn embedded_login_never_writes_command_stdout() { + assert!(!BrowserLoginOutput::Embedded.writes_command_stdout()); + assert!(BrowserLoginOutput::Standalone.writes_command_stdout()); + } #[test] fn callback_pages_are_self_contained_and_themed() { diff --git a/bb-cli/src/bb/auth_storage.rs b/bb-cli/src/bb/auth_storage.rs index 8bb0478d0..890c25550 100644 --- a/bb-cli/src/bb/auth_storage.rs +++ b/bb-cli/src/bb/auth_storage.rs @@ -2,8 +2,8 @@ use anyhow::Result; pub use builderbot_auth::auth_storage::{ default_session_storage_for_bb_home, stored_session_credential_header_value, - stored_session_credential_header_value_for_kgoose_base_url, PurposeTokenStorageKey, - SessionCredentialStorage, SessionStorageKey, StoredPurposeTokenCredential, + stored_session_credential_header_value_for_kgoose_base_url, SessionCredentialStorage, + SessionStorageKey, }; use super::skills_config::SkillsConfig; diff --git a/bb-cli/src/bb/skills.rs b/bb-cli/src/bb/skills.rs index ab83c77c1..b69d909e3 100644 --- a/bb-cli/src/bb/skills.rs +++ b/bb-cli/src/bb/skills.rs @@ -13,7 +13,7 @@ use serde_json::{json, Value}; use super::auth_login::{ logout_stored_session, run_browser_login, verify_stored_session, BrowserLoginCredentialSource, }; -use super::auth_storage::{default_session_storage, PurposeTokenStorageKey}; +use super::auth_storage::default_session_storage; use super::description::describe_command_tree; use super::display::{print_json, stdin_is_tty, terminal_safe_text, Style}; use super::org_routing::{normalize_org, resolve_org_kgoose_base_url}; @@ -655,7 +655,6 @@ fn auth_login_browser(config: &SkillsConfig) -> Result<()> { fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { let storage = default_session_storage(config)?; let storage_key = super::auth_storage::session_storage_key_from_config(config); - let purpose_token_key = PurposeTokenStorageKey::new(&storage_key, "compose"); let mut warnings = Vec::new(); let server_revoked = match logout_stored_session(config, storage.as_ref()) { Ok(server_revoked) => server_revoked, @@ -671,14 +670,6 @@ fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { false } }; - let purpose_token_removed = match storage.delete_purpose_token(&purpose_token_key) { - Ok(removed) => removed, - Err(err) => { - warnings.push(format!("failed to remove cached Compose credential: {err}")); - false - } - }; - if config.json { return print_json(&json!({ "profile": config.profile, @@ -687,7 +678,6 @@ fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { "storage": storage.kind(), "server_revoked": server_revoked, "removed": removed, - "purpose_token_removed": purpose_token_removed, "warnings": warnings, })); } diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 47b038ac2..1ac7527b0 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -7,7 +7,6 @@ mod common; use std::fs; use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; -use std::process::Stdio; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -1859,6 +1858,45 @@ fn bb_auth_status_uses_auth_me_for_stored_file_session() { fs::remove_dir_all(temp).expect("remove temp dir"); } +#[test] +fn bb_auth_status_errors_never_echo_a_reflected_session() { + let secret = "reflected_session_credential_123456"; + let server = MockServer::start(vec![ + MockResponse::text(500, secret), + MockResponse::text(500, secret), + ]); + let temp = temp_test_dir("bb-auth-status-redaction"); + let bb_home = temp.join("bb-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bb_org_config(&bb_home, "test"); + write_browser_auth_session( + &storage_path, + &server.base_url, + secret, + "2099-01-01T00:00:00Z", + ); + + for args in [vec!["auth", "status"], vec!["auth", "status", "--json"]] { + let output = bb_command() + .env("BB_HOME", &bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(args) + .output() + .expect("run failing bb auth status"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(!stdout.contains(secret)); + assert!(!stderr.contains(secret)); + assert!(stderr.contains("/v1/auth/me failed with 500")); + } + + assert_eq!(server.finish().len(), 2); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + #[test] fn bb_auth_login_uses_valid_stored_file_session() { let temp = temp_test_dir("bb-auth-login-stored"); @@ -3588,424 +3626,18 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } #[test] -fn bb_apps_contract_exchanges_session_and_calls_control_plane() { - let purpose_token = "compose-purpose-token"; - let session_credential = "stored-bbidentity-session"; - let contract = json!({ - "ok": true, - "contract_version": "2026-06-30", - "minimum_client_version": "0.1.0", - "supported_operations": [{ - "method": "GET", - "path": "/v1/agent/contract" - }] - }); - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": purpose_token, - "token_type": "Bearer", - "expires_at": "2099-01-01T00:05:00Z", - "expires_in_seconds": 300 - })), - MockResponse::json(contract.clone()), - ]); - let temp = temp_test_dir("bb-apps-contract"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - session_credential, - "2099-01-01T00:00:00Z", - ); - - let output = bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args([ - "apps", - "contract", - "--base-url", - &server.base_url, - "--client-version", - "0.2.0", - ]) - .output() - .expect("run bb apps contract"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse contract output"), - contract - ); - assert!(!stdout.contains(session_credential)); - assert!(!stdout.contains(purpose_token)); - assert!(!stderr.contains(session_credential)); - assert!(!stderr.contains(purpose_token)); - - assert_eq!(requests.len(), 2); - assert_eq!(requests[0].method, "POST"); - assert_eq!(requests[0].path, "/api/goose/v1/auth/token/compose"); - assert_eq!( - requests[0] - .headers - .get("x-bb-session-credential") - .map(String::as_str), - Some(session_credential) - ); - assert!(!requests[0].headers.contains_key("authorization")); - assert_eq!(requests[0].body, Value::Null); - - assert_eq!(requests[1].method, "GET"); - assert_eq!(requests[1].path, "/v1/agent/contract"); - assert_eq!( - requests[1].headers.get("authorization").map(String::as_str), - Some("Bearer compose-purpose-token") - ); - assert_eq!( - requests[1] - .headers - .get("x-hotpod-agent-client-version") - .map(String::as_str), - Some("0.2.0") - ); - for sensitive_header in [ - "x-bb-session-credential", - "x-forwarded-user", - "x-forwarded-workspace-id", - ] { - assert!( - !requests[1].headers.contains_key(sensitive_header), - "control-plane request unexpectedly included {sensitive_header}" - ); - } - assert_eq!(requests[1].body, Value::Null); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_create_plans_and_initializes_when_recommended() { - let purpose_token = "compose-create-purpose-token"; - let session_credential = "stored-bbidentity-session"; - let plan = json!({ - "ok": true, - "app_id": "merchant-lookup", - "display_name": "Merchant Lookup", - "environment": "staging", - "external_url": "https://merchant-lookup--bpsites.example/", - "persistence": "sqlite", - "runtime_class": "default", - "initialize": { - "required": true, - "recommended": true, - "reason": "no active route exists" - } - }); - let initialized = json!({ - "ok": true, - "app_id": "merchant-lookup-2", - "renamed": true, - "external_url": "https://merchant-lookup-2--bpsites.example/" - }); - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": purpose_token, - "token_type": "Bearer", - "expires_in_seconds": 300 - })), - MockResponse::json(plan.clone()), - MockResponse::json(initialized.clone()), - ]); - let temp = temp_test_dir("bb-apps-create"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - session_credential, - "2099-01-01T00:00:00Z", - ); - - let output = bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args([ - "apps", - "create", - "--app-id", - "merchant-lookup", - "--name", - "Merchant Lookup", - "--environment", - "staging", - "--runtime-profile", - "fetch-js", - "--persistence", - "sqlite", - "--base-url", - &server.base_url, - "--client-version", - "0.2.0", - ]) - .output() - .expect("run bb apps create"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["app_id"], json!("merchant-lookup-2")); - assert_eq!( - response["external_url"], - json!("https://merchant-lookup-2--bpsites.example/") - ); - assert_eq!(response["initialized"], json!(true)); - assert_eq!(response["plan"], plan); - assert_eq!(response["initialize"], initialized); - assert!(!stdout.contains(session_credential)); - assert!(!stdout.contains(purpose_token)); - assert!(!stderr.contains(session_credential)); - assert!(!stderr.contains(purpose_token)); - - assert_eq!(requests.len(), 3); - assert_eq!(requests[1].method, "POST"); - assert_eq!(requests[1].path, "/v1/agent/apps/plan"); - assert_eq!( - requests[1].body, - json!({ - "app_id": "merchant-lookup", - "name": "Merchant Lookup", - "environment": "staging", - "runtime_profile": "fetch-js", - "persistence": "sqlite", - "client_version": "0.2.0" - }) - ); - assert_eq!(requests[2].method, "POST"); - assert_eq!( - requests[2].path, - "/v1/agent/apps/merchant-lookup/initialize" - ); - assert_eq!( - requests[2].body, - json!({ - "name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default" - }) - ); - for request in &requests[1..] { - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some("Bearer compose-create-purpose-token") - ); - assert_eq!( - request - .headers - .get("x-hotpod-agent-client-version") - .map(String::as_str), - Some("0.2.0") - ); - for sensitive_header in [ - "x-bb-session-credential", - "x-forwarded-user", - "x-forwarded-workspace-id", - ] { - assert!(!request.headers.contains_key(sensitive_header)); - } - } - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_create_skips_initialize_when_plan_does_not_recommend_it() { - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": "compose-create-purpose-token", - "token_type": "Bearer", - "expires_in_seconds": 300 - })), - MockResponse::json(json!({ - "ok": true, - "app_id": "existing-app", - "initialize": {"required": false, "recommended": false} - })), - ]); - let temp = temp_test_dir("bb-apps-create-existing"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - "stored-bbidentity-session", - "2099-01-01T00:00:00Z", - ); - - let output = bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args([ - "apps", - "create", - "--app-id", - "existing-app", - "--base-url", - &server.base_url, - ]) - .output() - .expect("run bb apps create for existing app"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["initialized"], json!(false)); - assert_eq!(response["initialize"], Value::Null); - assert_eq!(requests.len(), 2); - assert_eq!(requests[1].path, "/v1/agent/apps/plan"); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_deploy_uploads_multipart_artifact_without_identity_headers() { - let purpose_token = "compose-deploy-purpose-token"; - let session_credential = "stored-bbidentity-session"; - let deploy_response = json!({ - "ok": true, - "app_id": "merchant-lookup", - "version_id": "ver-123", - "deployment_id": "dpl-123", - "external_url": "https://merchant-lookup--bpsites.example/", - "readiness": { - "control_plane_url": "/v1/agent/apps/merchant-lookup/ready?version_id=ver-123", - "diagnostics_url": "/v1/agent/apps/merchant-lookup/debug?version_id=ver-123" - } - }); - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": purpose_token, - "token_type": "Bearer", - "expires_in_seconds": 300 - })), - MockResponse::json(deploy_response.clone()), - ]); - let temp = temp_test_dir("bb-apps-deploy"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - let artifact_path = temp.join("prepared-app.tar.gz"); - let artifact_marker = "test-hotpod-artifact-marker"; - fs::write(&artifact_path, artifact_marker).expect("write deploy artifact"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - session_credential, - "2099-01-01T00:00:00Z", - ); - - let output = bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args([ - "apps", - "deploy", - "merchant-lookup", - artifact_path.to_str().expect("artifact path text"), - "--environment", - "production", - "--version-id", - "ver-123", - "--deployment-id", - "dpl-123", - "--base-url", - &server.base_url, - "--client-version", - "0.2.0", - ]) - .output() - .expect("run bb apps deploy"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse deploy output"), - deploy_response - ); - assert!(!stdout.contains(session_credential)); - assert!(!stdout.contains(purpose_token)); - assert!(!stderr.contains(session_credential)); - assert!(!stderr.contains(purpose_token)); - - assert_eq!(requests.len(), 2); - let request = &requests[1]; - assert_eq!(request.method, "POST"); - assert_eq!(request.path, "/v1/agent/apps/merchant-lookup/deploy"); - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some("Bearer compose-deploy-purpose-token") - ); - assert_eq!( - request - .headers - .get("x-hotpod-agent-client-version") - .map(String::as_str), - Some("0.2.0") - ); - assert!(request - .headers - .get("content-type") - .is_some_and(|value| value.starts_with("multipart/form-data; boundary="))); - for sensitive_header in [ - "x-bb-session-credential", - "x-forwarded-user", - "x-forwarded-workspace-id", - ] { - assert!(!request.headers.contains_key(sensitive_header)); - } - let body = String::from_utf8_lossy(&request.body_bytes); - for expected in [ - "name=\"artifact\"; filename=\"artifact.tar.gz\"", - "Content-Type: application/gzip", - artifact_marker, - "name=\"environment\"\r\n\r\nproduction", - "name=\"version_id\"\r\n\r\nver-123", - "name=\"deployment_id\"\r\n\r\ndpl-123", - ] { - assert!( - body.contains(expected), - "multipart body omitted {expected:?}" - ); - } - assert!(!body.contains("publisher")); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_contract_rejects_untrusted_origin_before_token_exchange() { +fn bb_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { let kgoose = MockServer::start(vec![]); - let temp = temp_test_dir("bb-apps-untrusted-origin"); + let control_plane = MockServer::start(vec![]); + let temp = temp_test_dir("bb-apps-loopback-origin"); let bb_home = temp.join("bb-home"); let storage_path = temp.join("auth-sessions.json"); + let session_credential = "stored_session_credential_1234567890"; write_bb_org_config(&bb_home, "test"); write_browser_auth_session( &storage_path, &kgoose.base_url, - "stored-bbidentity-session", + session_credential, "2099-01-01T00:00:00Z", ); @@ -4014,203 +3646,38 @@ fn bb_apps_contract_rejects_untrusted_origin_before_token_exchange() { .env("BB_AUTH_STORAGE", "file") .env("BB_AUTH_STORAGE_FILE", &storage_path) .env("KGOOSE_BASE_URL", &kgoose.base_url) - .args(["apps", "contract", "--base-url", "https://attacker.example"]) + .args(["apps", "contract", "--base-url", &control_plane.base_url]) .output() - .expect("run bb apps contract with untrusted origin"); - let requests = kgoose.finish(); + .expect("run bb apps contract with loopback origin"); + let kgoose_requests = kgoose.finish(); + let control_plane_requests = control_plane.finish(); let (stdout, stderr) = output_text(&output); assert!(!output.status.success()); assert!(stdout.is_empty(), "stdout was: {stdout}"); assert!(stderr.contains("approved Builderlab ingress")); - assert!(requests.is_empty(), "token exchange must not be attempted"); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_contract_shares_purpose_token_between_concurrent_processes() { - let purpose_token = "shared-compose-purpose-token"; - let session_credential = "stored-bbidentity-session"; - let contract = json!({ - "contract_version": "2026-06-30", - "supported_operations": [] - }); - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": purpose_token, - "token_type": "Bearer", - "expires_at": "2099-01-01T00:05:00Z", - "expires_in_seconds": 300 - })), - MockResponse::json(contract.clone()), - MockResponse::json(contract.clone()), - ]); - let temp = temp_test_dir("bb-apps-shared-purpose-token"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - session_credential, - "2099-01-01T00:00:00Z", - ); - - let mut children = Vec::new(); - for _ in 0..2 { - children.push( - bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args(["apps", "contract", "--base-url", &server.base_url]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn bb apps contract"), - ); - } - - for (index, child) in children.into_iter().enumerate() { - let output = child.wait_with_output().expect("wait for bb apps contract"); - let (stdout, stderr) = output_text(&output); - - assert!( - output.status.success(), - "invocation {} failed: {stderr}", - index + 1 - ); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse contract output"), - contract - ); - assert!(!stdout.contains(purpose_token)); - assert!(!stderr.contains(purpose_token)); - } - - let requests = server.finish(); - assert_eq!(requests.len(), 3); - assert_eq!( - requests - .iter() - .filter(|request| request.path == "/api/goose/v1/auth/token/compose") - .count(), - 1 - ); - let control_plane_requests = requests - .iter() - .filter(|request| request.path == "/v1/agent/contract") - .collect::>(); - assert_eq!(control_plane_requests.len(), 2); - for request in control_plane_requests { - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some("Bearer shared-compose-purpose-token") - ); - } - - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_exchange_failure_does_not_echo_response_or_credentials() { - let secret = "credential-that-must-not-be-logged"; - let server = MockServer::start(vec![MockResponse::text(403, secret)]); - let temp = temp_test_dir("bb-apps-exchange-failure"); - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - secret, - "2099-01-01T00:00:00Z", - ); - - let output = bb_command() - .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args(["apps", "contract", "--base-url", &server.base_url, "--json"]) - .output() - .expect("run failing bb apps contract"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(!output.status.success()); - assert!(stdout.is_empty(), "stdout was: {stdout}"); - assert!( - !stderr.contains(secret), - "stderr leaked credential: {stderr}" - ); - let error = parse_stderr_error(&stderr); - assert_eq!(error["error"]["code"], json!("forbidden")); - assert_eq!(requests.len(), 1, "request should stop after exchange"); + assert!(!stderr.contains(session_credential)); + assert!(kgoose_requests.is_empty()); + assert!(control_plane_requests.is_empty()); fs::remove_dir_all(temp).expect("remove temp dir"); } #[test] -fn bb_apps_control_plane_failure_does_not_echo_response_details() { - let secret = "credential-like-control-plane-detail"; - let server = MockServer::start(vec![ - MockResponse::json(json!({ - "access_token": "compose-purpose-token", - "token_type": "Bearer", - "expires_in_seconds": 300 - })), - MockResponse::text( - 400, - &json!({ - "error": {"code": "invalid_app", "detail": secret}, - "next_action": "Choose a DNS-safe app id." - }) - .to_string(), - ), - ]); - let temp = temp_test_dir("bb-apps-control-plane-failure"); +fn bb_apps_contract_rejects_arbitrary_https_origin() { + let temp = temp_test_dir("bb-apps-arbitrary-origin"); let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - "stored-bbidentity-session", - "2099-01-01T00:00:00Z", - ); let output = bb_command() .env("BB_HOME", &bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", &storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .args([ - "apps", - "create", - "--app-id", - "bad/app", - "--base-url", - &server.base_url, - "--json", - ]) + .args(["apps", "contract", "--base-url", "https://attacker.example"]) .output() - .expect("run failing bb apps create"); - let requests = server.finish(); + .expect("run bb apps contract with arbitrary origin"); let (stdout, stderr) = output_text(&output); assert!(!output.status.success()); assert!(stdout.is_empty(), "stdout was: {stdout}"); - assert!( - !stderr.contains(secret), - "stderr leaked response detail: {stderr}" - ); - let error = parse_stderr_error(&stderr); - assert_eq!(error["error"]["code"], json!("invalid_app")); - assert!(error["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("Choose a DNS-safe app id."))); - assert_eq!(requests.len(), 2); + assert!(stderr.contains("approved Builderlab ingress")); fs::remove_dir_all(temp).expect("remove temp dir"); } diff --git a/crates/builderbot-auth/src/auth_login.rs b/crates/builderbot-auth/src/auth_login.rs index 81c9ae657..dcbd7029c 100644 --- a/crates/builderbot-auth/src/auth_login.rs +++ b/crates/builderbot-auth/src/auth_login.rs @@ -84,12 +84,10 @@ pub fn exchange_login_code( } let response = request.send().context("exchange login code")?; let status = response.status(); - let body = response.text().context("read login exchange response")?; if !status.is_success() { - return Err(anyhow!( - "/v1/auth/login/exchange failed with {status}: {body}" - )); + return Err(anyhow!("/v1/auth/login/exchange failed with {status}")); } + let body = response.text().context("read login exchange response")?; serde_json::from_str(&body).context("parse login exchange response") } @@ -133,13 +131,13 @@ pub fn verify_session_credential( .send() .context("verify stored BuilderBot CLI auth session")?; let status = response.status(); - let body = response.text().context("read /v1/auth/me response")?; if status == HttpStatusCode::UNAUTHORIZED || status == HttpStatusCode::FORBIDDEN { return Ok(None); } if !status.is_success() { - return Err(anyhow!("/v1/auth/me failed with {status}: {body}")); + return Err(anyhow!("/v1/auth/me failed with {status}")); } + let body = response.text().context("read /v1/auth/me response")?; let me: AuthMeResponse = serde_json::from_str(&body).context("parse /v1/auth/me response")?; Ok(Some(me)) } @@ -166,12 +164,11 @@ pub fn logout_session_credential( .send() .context("destroy stored BuilderBot CLI auth session")?; let status = response.status(); - let body = response.text().context("read /v1/auth/logout response")?; if status == HttpStatusCode::UNAUTHORIZED || status == HttpStatusCode::FORBIDDEN { return Ok(false); } if !status.is_success() { - return Err(anyhow!("/v1/auth/logout failed with {status}: {body}")); + return Err(anyhow!("/v1/auth/logout failed with {status}")); } Ok(true) } @@ -232,6 +229,26 @@ mod tests { ); } + #[test] + fn verify_session_credential_does_not_echo_failure_body() { + let secret = "reflected_session_credential_123456"; + let server = SingleResponseServer::start(500, secret); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: secret.to_string(), + expires_at: None, + }; + + let error = verify_session_credential(&client, None, &server.base_url, &credential) + .expect_err("reject failed session check"); + let request = server.finish(); + let message = format!("{error:#}"); + + assert!(message.contains("/v1/auth/me failed with 500")); + assert!(!message.contains(secret)); + assert_eq!(request.path, "/v1/auth/me"); + } + #[test] fn verify_session_credential_skips_empty_stored_credential() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused server"); diff --git a/crates/builderbot-auth/src/auth_storage.rs b/crates/builderbot-auth/src/auth_storage.rs index e9eac78c4..28962f01d 100644 --- a/crates/builderbot-auth/src/auth_storage.rs +++ b/crates/builderbot-auth/src/auth_storage.rs @@ -1,10 +1,8 @@ use std::collections::BTreeMap; #[cfg(any(debug_assertions, test))] use std::collections::HashMap; -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(any(debug_assertions, test))] use std::sync::Mutex; @@ -16,11 +14,8 @@ use crate::config::kgoose_service_url; #[cfg(target_os = "macos")] const KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth"; -#[cfg(target_os = "macos")] -const PURPOSE_TOKEN_KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth-purpose-token"; pub const BB_AUTH_STORAGE_ENV_VAR: &str = "BB_AUTH_STORAGE"; pub const BB_AUTH_STORAGE_FILE_ENV_VAR: &str = "BB_AUTH_STORAGE_FILE"; -static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone)] pub struct SessionStorageKey { @@ -65,40 +60,6 @@ impl SessionStorageKey { } } -#[derive(Debug, Clone)] -pub struct PurposeTokenStorageKey { - session: SessionStorageKey, - purpose: String, -} - -impl PurposeTokenStorageKey { - pub fn new(session: &SessionStorageKey, purpose: impl Into) -> Self { - Self { - session: session.clone(), - purpose: purpose.into(), - } - } - - #[cfg(target_os = "macos")] - fn account(&self) -> String { - format!("{}@{}", self.purpose, self.session.account()) - } - - fn hashed_id(&self) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"purpose-token"); - hasher.update([0]); - hasher.update(self.purpose.as_bytes()); - hasher.update([0]); - hasher.update(self.session.hashed_id().as_bytes()); - hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StoredSessionCredential { @@ -118,31 +79,11 @@ impl StoredSessionCredential { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StoredPurposeTokenCredential { - pub access_token: String, - pub token_type: String, - pub issued_at_unix_seconds: u64, - pub expires_at_unix_seconds: u64, - pub session_credential_sha256: String, -} - pub trait SessionCredentialStorage { fn kind(&self) -> &'static str; fn get(&self, key: &SessionStorageKey) -> Result>; fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()>; fn delete(&self, key: &SessionStorageKey) -> Result; - fn get_purpose_token( - &self, - key: &PurposeTokenStorageKey, - ) -> Result>; - fn set_purpose_token( - &self, - key: &PurposeTokenStorageKey, - credential: &StoredPurposeTokenCredential, - ) -> Result<()>; - fn delete_purpose_token(&self, key: &PurposeTokenStorageKey) -> Result; } pub fn default_session_storage_for_bb_home( @@ -249,7 +190,6 @@ fn file_storage_from_env(bb_home: &std::path::Path) -> Result>, - purpose_tokens: Mutex>, } #[cfg(any(debug_assertions, test))] @@ -283,39 +223,6 @@ impl SessionCredentialStorage for InMemorySessionCredentialStorage { .remove(&key.hashed_id()) .is_some()) } - - fn get_purpose_token( - &self, - key: &PurposeTokenStorageKey, - ) -> Result> { - Ok(self - .purpose_tokens - .lock() - .expect("purpose token storage mutex poisoned") - .get(&key.hashed_id()) - .cloned()) - } - - fn set_purpose_token( - &self, - key: &PurposeTokenStorageKey, - credential: &StoredPurposeTokenCredential, - ) -> Result<()> { - self.purpose_tokens - .lock() - .expect("purpose token storage mutex poisoned") - .insert(key.hashed_id(), credential.clone()); - Ok(()) - } - - fn delete_purpose_token(&self, key: &PurposeTokenStorageKey) -> Result { - Ok(self - .purpose_tokens - .lock() - .expect("purpose token storage mutex poisoned") - .remove(&key.hashed_id()) - .is_some()) - } } #[derive(Debug)] @@ -345,30 +252,6 @@ impl FileSessionCredentialStorage { fs::write(&self.path, json).with_context(|| format!("write {}", self.path.display()))?; restrict_permissions(&self.path) } - - fn purpose_tokens_path(&self) -> PathBuf { - let mut path = self.path.as_os_str().to_os_string(); - path.push(".purpose-tokens"); - PathBuf::from(path) - } - - fn read_purpose_tokens(&self) -> Result> { - let path = self.purpose_tokens_path(); - if !path.exists() { - return Ok(BTreeMap::new()); - } - let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display())) - } - - fn write_purpose_tokens( - &self, - entries: &BTreeMap, - ) -> Result<()> { - let path = self.purpose_tokens_path(); - let json = serde_json::to_vec_pretty(entries).context("serialize purpose token storage")?; - write_private_file_atomically(&path, &json) - } } impl SessionCredentialStorage for FileSessionCredentialStorage { @@ -394,32 +277,6 @@ impl SessionCredentialStorage for FileSessionCredentialStorage { } Ok(removed) } - - fn get_purpose_token( - &self, - key: &PurposeTokenStorageKey, - ) -> Result> { - Ok(self.read_purpose_tokens()?.get(&key.hashed_id()).cloned()) - } - - fn set_purpose_token( - &self, - key: &PurposeTokenStorageKey, - credential: &StoredPurposeTokenCredential, - ) -> Result<()> { - let mut entries = self.read_purpose_tokens()?; - entries.insert(key.hashed_id(), credential.clone()); - self.write_purpose_tokens(&entries) - } - - fn delete_purpose_token(&self, key: &PurposeTokenStorageKey) -> Result { - let mut entries = self.read_purpose_tokens()?; - let removed = entries.remove(&key.hashed_id()).is_some(); - if removed { - self.write_purpose_tokens(&entries)?; - } - Ok(removed) - } } #[derive(Debug)] @@ -441,25 +298,6 @@ impl SessionCredentialStorage for KeyringSessionCredentialStorage { fn delete(&self, key: &SessionStorageKey) -> Result { keyring_delete(key) } - - fn get_purpose_token( - &self, - key: &PurposeTokenStorageKey, - ) -> Result> { - keyring_get_purpose_token(key) - } - - fn set_purpose_token( - &self, - key: &PurposeTokenStorageKey, - credential: &StoredPurposeTokenCredential, - ) -> Result<()> { - keyring_set_purpose_token(key, credential) - } - - fn delete_purpose_token(&self, key: &PurposeTokenStorageKey) -> Result { - keyring_delete_purpose_token(key) - } } #[cfg(target_os = "macos")] @@ -496,42 +334,6 @@ fn keyring_delete(key: &SessionStorageKey) -> Result { .context("delete BuilderBot auth session from keyring") } -#[cfg(target_os = "macos")] -fn keyring_get_purpose_token( - key: &PurposeTokenStorageKey, -) -> Result> { - use crate::keychain; - - let value = - keychain::get_generic_password_unscoped(PURPOSE_TOKEN_KEYRING_SERVICE, &key.account()) - .context("read BuilderBot purpose token from keyring")?; - value - .map(|value| { - serde_json::from_slice(&value).context("parse BuilderBot purpose token from keyring") - }) - .transpose() -} - -#[cfg(target_os = "macos")] -fn keyring_set_purpose_token( - key: &PurposeTokenStorageKey, - credential: &StoredPurposeTokenCredential, -) -> Result<()> { - use crate::keychain; - - let value = serde_json::to_vec(credential).context("serialize BuilderBot purpose token")?; - keychain::set_generic_password_unscoped(PURPOSE_TOKEN_KEYRING_SERVICE, &key.account(), &value) - .context("write BuilderBot purpose token to keyring") -} - -#[cfg(target_os = "macos")] -fn keyring_delete_purpose_token(key: &PurposeTokenStorageKey) -> Result { - use crate::keychain; - - keychain::delete_generic_password_unscoped(PURPOSE_TOKEN_KEYRING_SERVICE, &key.account()) - .context("delete BuilderBot purpose token from keyring") -} - #[cfg(not(target_os = "macos"))] fn keyring_get(_key: &SessionStorageKey) -> Result> { unsupported_keyring_storage() @@ -547,26 +349,6 @@ fn keyring_delete(_key: &SessionStorageKey) -> Result { unsupported_keyring_storage() } -#[cfg(not(target_os = "macos"))] -fn keyring_get_purpose_token( - _key: &PurposeTokenStorageKey, -) -> Result> { - unsupported_keyring_storage() -} - -#[cfg(not(target_os = "macos"))] -fn keyring_set_purpose_token( - _key: &PurposeTokenStorageKey, - _credential: &StoredPurposeTokenCredential, -) -> Result<()> { - unsupported_keyring_storage() -} - -#[cfg(not(target_os = "macos"))] -fn keyring_delete_purpose_token(_key: &PurposeTokenStorageKey) -> Result { - unsupported_keyring_storage() -} - #[cfg(not(target_os = "macos"))] fn unsupported_keyring_storage() -> Result { anyhow::bail!( @@ -596,46 +378,6 @@ fn restrict_permissions(path: &PathBuf) -> Result<()> { Ok(()) } -fn write_private_file_atomically(path: &PathBuf, bytes: &[u8]) -> Result<()> { - if let Some(parent) = path.parent() { - let existed = parent.exists(); - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - #[cfg(unix)] - if !existed { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) - .with_context(|| format!("chmod 700 {}", parent.display()))?; - } - } - - let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let mut temporary_path = path.as_os_str().to_os_string(); - temporary_path.push(format!(".{}.{}.tmp", std::process::id(), sequence)); - let temporary_path = PathBuf::from(temporary_path); - let result = (|| { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&temporary_path) - .with_context(|| format!("create {}", temporary_path.display()))?; - file.write_all(bytes) - .with_context(|| format!("write {}", temporary_path.display()))?; - file.sync_all() - .with_context(|| format!("sync {}", temporary_path.display()))?; - fs::rename(&temporary_path, path).with_context(|| format!("replace {}", path.display()))?; - restrict_permissions(path) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary_path); - } - result -} - #[cfg(test)] mod tests { use super::*; @@ -714,73 +456,6 @@ mod tests { let _ = fs::remove_dir_all(directory); } - #[test] - fn file_storage_scopes_and_protects_purpose_tokens() { - let directory = std::env::temp_dir().join(format!( - "bb-purpose-token-storage-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - let storage = FileSessionCredentialStorage::new(directory.join("sessions.json")); - let local_session = SessionStorageKey::new("default", "http://localhost:5173"); - let staging_session = SessionStorageKey::new("default", "https://staging.example"); - let local_compose = PurposeTokenStorageKey::new(&local_session, "compose"); - let staging_compose = PurposeTokenStorageKey::new(&staging_session, "compose"); - let local_other = PurposeTokenStorageKey::new(&local_session, "other"); - let credential = StoredPurposeTokenCredential { - access_token: "purpose-token".to_string(), - token_type: "Bearer".to_string(), - issued_at_unix_seconds: 100, - expires_at_unix_seconds: 400, - session_credential_sha256: "session-fingerprint".to_string(), - }; - - storage - .set_purpose_token(&local_compose, &credential) - .expect("store purpose token"); - - assert_eq!( - storage - .get_purpose_token(&local_compose) - .expect("read purpose token") - .expect("purpose token") - .access_token, - "purpose-token" - ); - assert!(storage - .get_purpose_token(&staging_compose) - .expect("read staging purpose token") - .is_none()); - assert!(storage - .get_purpose_token(&local_other) - .expect("read other purpose token") - .is_none()); - - let purpose_tokens_path = storage.purpose_tokens_path(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&purpose_tokens_path) - .expect("purpose token metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - assert!(storage - .delete_purpose_token(&local_compose) - .expect("delete purpose token")); - assert!(!storage - .delete_purpose_token(&local_compose) - .expect("delete purpose token again")); - - let _ = fs::remove_dir_all(directory); - } - #[test] fn parse_stored_session_accepts_legacy_raw_credential() { let stored = parse_stored_session("raw-session").expect("parse raw credential"); @@ -818,15 +493,5 @@ mod tests { key.account(), "default@https://kgoose.stage.sqprod.co/cash-app/goose" ); - - let purpose_key = PurposeTokenStorageKey::new(&key, "compose"); - assert_eq!( - PURPOSE_TOKEN_KEYRING_SERVICE, - "com.squareup.builderbot.cli-auth-purpose-token" - ); - assert_eq!( - purpose_key.account(), - "compose@default@https://kgoose.stage.sqprod.co/cash-app/goose" - ); } } From dce81fe5dc0ac7c7ede2f291b5710d1029d7db42 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Wed, 12 Aug 2026 18:31:38 -0400 Subject: [PATCH 02/10] Avoid logging allowlisted URLs in tests --- bb-cli/src/bb/apps.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index cdbab5c17..6a07a8883 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -795,8 +795,10 @@ mod tests { "https://compose-ctrl.app.builderlab.xyz", "https://compose-ctrl.test.blockstaging.build:443", ] { - ControlPlaneClient::new(trusted, "1.0.0", style) - .unwrap_or_else(|error| panic!("trusted URL {trusted} was rejected: {error}")); + assert!( + ControlPlaneClient::new(trusted, "1.0.0", style).is_ok(), + "allowlisted control-plane origin should be accepted" + ); } for untrusted in [ From 6bb12e677267f2a31c32e0c83d568cd967bd4b2e Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 13 Aug 2026 13:08:55 -0400 Subject: [PATCH 03/10] Assert Compose requests omit cookies --- bb-cli/src/bb/apps.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index 6a07a8883..3d902f393 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -843,6 +843,7 @@ mod tests { Some("BBIdentity opaque_session_credential_1234567890") ); for forbidden in [ + "Cookie", "X-BB-Session-Credential", "X-Forwarded-User", "X-Forwarded-Workspace-Id", From 1be4c687d55d441626cf3ff8fe5e22a22115492b Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 13 Aug 2026 14:20:11 -0400 Subject: [PATCH 04/10] Clean up legacy Compose token caches --- bb-cli/src/bb/skills.rs | 10 +++ bb-cli/tests/bb_e2e.rs | 12 ++++ crates/builderbot-auth/src/auth_storage.rs | 81 ++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/bb-cli/src/bb/skills.rs b/bb-cli/src/bb/skills.rs index b69d909e3..03afa4741 100644 --- a/bb-cli/src/bb/skills.rs +++ b/bb-cli/src/bb/skills.rs @@ -670,6 +670,15 @@ fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { false } }; + let purpose_token_removed = match storage.delete_legacy_purpose_token_cache(&storage_key) { + Ok(removed) => removed, + Err(err) => { + warnings.push(format!( + "failed to remove legacy cached Compose credential: {err}" + )); + false + } + }; if config.json { return print_json(&json!({ "profile": config.profile, @@ -678,6 +687,7 @@ fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { "storage": storage.kind(), "server_revoked": server_revoked, "removed": removed, + "purpose_token_removed": purpose_token_removed, "warnings": warnings, })); } diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 1ac7527b0..4b7fe6898 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -2019,6 +2019,7 @@ fn bb_auth_logout_removes_stored_file_session() { let server_url = format!("{}/api/goose", server.base_url); let default_key = browser_auth_storage_key("default", &server_url); let other_key = browser_auth_storage_key("other", &server_url); + let purpose_storage_path = PathBuf::from(format!("{}.purpose-tokens", storage_path.display())); fs::write( &storage_path, serde_json::to_string_pretty(&json!({ @@ -2034,6 +2035,14 @@ fn bb_auth_logout_removes_stored_file_session() { .expect("serialize storage"), ) .expect("write auth storage"); + fs::write( + &purpose_storage_path, + serde_json::to_string_pretty(&json!({ + "obsolete-purpose-token": { "accessToken": "legacy-secret" } + })) + .expect("serialize legacy purpose token storage"), + ) + .expect("write legacy purpose token storage"); let output = bb_command() .env("BB_HOME", &bb_home) @@ -2051,6 +2060,7 @@ fn bb_auth_logout_removes_stored_file_session() { assert_eq!(response["removed"], json!(true)); assert_eq!(response["server_revoked"], json!(true)); assert_eq!(response["storage"], json!("file")); + assert_eq!(response["purpose_token_removed"], json!(true)); assert_eq!(requests.len(), 1); assert_eq!(requests[0].method, "POST"); assert_eq!(requests[0].path, "/api/goose/v1/auth/logout"); @@ -2065,6 +2075,7 @@ fn bb_auth_logout_removes_stored_file_session() { let storage = fs::read_to_string(&storage_path).expect("read storage"); assert!(!storage.contains("default-session")); assert!(storage.contains("other-session")); + assert!(!purpose_storage_path.exists()); let output = bb_command() .env("BB_HOME", &bb_home) @@ -2080,6 +2091,7 @@ fn bb_auth_logout_removes_stored_file_session() { let response = serde_json::from_str::(&stdout).expect("parse logout output"); assert_eq!(response["removed"], json!(false)); assert_eq!(response["server_revoked"], json!(false)); + assert_eq!(response["purpose_token_removed"], json!(false)); fs::remove_dir_all(temp).expect("remove temp dir"); } diff --git a/crates/builderbot-auth/src/auth_storage.rs b/crates/builderbot-auth/src/auth_storage.rs index 28962f01d..69ed0c76a 100644 --- a/crates/builderbot-auth/src/auth_storage.rs +++ b/crates/builderbot-auth/src/auth_storage.rs @@ -14,6 +14,8 @@ use crate::config::kgoose_service_url; #[cfg(target_os = "macos")] const KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth"; +#[cfg(target_os = "macos")] +const LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth-purpose-token"; pub const BB_AUTH_STORAGE_ENV_VAR: &str = "BB_AUTH_STORAGE"; pub const BB_AUTH_STORAGE_FILE_ENV_VAR: &str = "BB_AUTH_STORAGE_FILE"; @@ -60,6 +62,11 @@ impl SessionStorageKey { } } +#[cfg(target_os = "macos")] +fn legacy_compose_token_account(session: &SessionStorageKey) -> String { + format!("compose@{}", session.account()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StoredSessionCredential { @@ -84,6 +91,9 @@ pub trait SessionCredentialStorage { fn get(&self, key: &SessionStorageKey) -> Result>; fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()>; fn delete(&self, key: &SessionStorageKey) -> Result; + fn delete_legacy_purpose_token_cache(&self, _key: &SessionStorageKey) -> Result { + Ok(false) + } } pub fn default_session_storage_for_bb_home( @@ -252,6 +262,12 @@ impl FileSessionCredentialStorage { fs::write(&self.path, json).with_context(|| format!("write {}", self.path.display()))?; restrict_permissions(&self.path) } + + fn legacy_purpose_tokens_path(&self) -> PathBuf { + let mut path = self.path.as_os_str().to_os_string(); + path.push(".purpose-tokens"); + PathBuf::from(path) + } } impl SessionCredentialStorage for FileSessionCredentialStorage { @@ -277,6 +293,17 @@ impl SessionCredentialStorage for FileSessionCredentialStorage { } Ok(removed) } + + fn delete_legacy_purpose_token_cache(&self, _key: &SessionStorageKey) -> Result { + let path = self.legacy_purpose_tokens_path(); + if !path.exists() { + return Ok(false); + } + // Purpose-token storage has no remaining readers or writers. Remove + // the obsolete file as a whole instead of rewriting secrets in place. + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + Ok(true) + } } #[derive(Debug)] @@ -298,6 +325,10 @@ impl SessionCredentialStorage for KeyringSessionCredentialStorage { fn delete(&self, key: &SessionStorageKey) -> Result { keyring_delete(key) } + + fn delete_legacy_purpose_token_cache(&self, key: &SessionStorageKey) -> Result { + keyring_delete_legacy_compose_token(key) + } } #[cfg(target_os = "macos")] @@ -334,6 +365,17 @@ fn keyring_delete(key: &SessionStorageKey) -> Result { .context("delete BuilderBot auth session from keyring") } +#[cfg(target_os = "macos")] +fn keyring_delete_legacy_compose_token(key: &SessionStorageKey) -> Result { + use crate::keychain; + + keychain::delete_generic_password_unscoped( + LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE, + &legacy_compose_token_account(key), + ) + .context("delete legacy BuilderBot Compose token from keyring") +} + #[cfg(not(target_os = "macos"))] fn keyring_get(_key: &SessionStorageKey) -> Result> { unsupported_keyring_storage() @@ -349,6 +391,11 @@ fn keyring_delete(_key: &SessionStorageKey) -> Result { unsupported_keyring_storage() } +#[cfg(not(target_os = "macos"))] +fn keyring_delete_legacy_compose_token(_key: &SessionStorageKey) -> Result { + unsupported_keyring_storage() +} + #[cfg(not(target_os = "macos"))] fn unsupported_keyring_storage() -> Result { anyhow::bail!( @@ -456,6 +503,32 @@ mod tests { let _ = fs::remove_dir_all(directory); } + #[test] + fn file_storage_deletes_the_obsolete_legacy_purpose_token_cache() { + let directory = std::env::temp_dir().join(format!( + "bb-auth-storage-legacy-compose-delete-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&directory).expect("create test directory"); + let storage = FileSessionCredentialStorage::new(directory.join("sessions.json")); + let local = SessionStorageKey::new("default", "http://localhost:5173"); + let path = storage.legacy_purpose_tokens_path(); + fs::write(&path, b"legacy purpose-token contents").expect("write legacy tokens"); + + assert!(storage + .delete_legacy_purpose_token_cache(&local) + .expect("delete legacy purpose-token cache")); + assert!(!storage + .delete_legacy_purpose_token_cache(&local) + .expect("delete legacy purpose-token cache again")); + assert!(!path.exists()); + + let _ = fs::remove_dir_all(directory); + } + #[test] fn parse_stored_session_accepts_legacy_raw_credential() { let stored = parse_stored_session("raw-session").expect("parse raw credential"); @@ -493,5 +566,13 @@ mod tests { key.account(), "default@https://kgoose.stage.sqprod.co/cash-app/goose" ); + assert_eq!( + LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE, + "com.squareup.builderbot.cli-auth-purpose-token" + ); + assert_eq!( + legacy_compose_token_account(&key), + "compose@default@https://kgoose.stage.sqprod.co/cash-app/goose" + ); } } From 83874233a237d19288fdf1254881d2fd29d8b279 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 17:06:49 -0400 Subject: [PATCH 05/10] Harden embedded Compose authentication --- bb-cli/src/bb/apps.rs | 35 ++++++++++----------- bb-cli/src/bb/auth_login.rs | 54 +++++++++++++++++++++++++++---- bb-cli/tests/bb_e2e.rs | 63 ++++++++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 26 deletions(-) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index 3d902f393..d1b87b2f7 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -25,9 +25,9 @@ use reqwest::StatusCode; use serde::Serialize; use serde_json::{json, Map, Value}; -use super::auth_login::ensure_browser_login; +use super::auth_login::{ensure_browser_login, verify_stored_session}; use super::auth_storage::{default_session_storage, session_storage_key_from_config}; -use super::display::{print_json, terminal_safe_text, Style}; +use super::display::{print_json, stdin_is_tty, terminal_safe_text, Style}; use super::runner; use super::skills_api::{exit_codes, failure}; use super::skills_config::SkillsConfig; @@ -358,9 +358,16 @@ impl ComposeSessionCredential { fn from_config(config: &SkillsConfig) -> Result { let storage = default_session_storage(config)?; let session_storage_key = session_storage_key_from_config(config); - Self::after_login(storage.as_ref(), &session_storage_key, || { - ensure_browser_login(config, storage.as_ref()) - }) + if config.json || !stdin_is_tty() { + if verify_stored_session(config, storage.as_ref())?.is_none() { + return Err(auth_required_error()); + } + Self::after_login(storage.as_ref(), &session_storage_key, || Ok(())) + } else { + Self::after_login(storage.as_ref(), &session_storage_key, || { + ensure_browser_login(config, storage.as_ref()) + }) + } } fn after_login( @@ -382,13 +389,6 @@ impl ComposeSessionCredential { } fn new(secret: String) -> Result { - if !(32..=512).contains(&secret.len()) - || !secret - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - { - anyhow::bail!("stored BuilderBot CLI auth session is invalid; run `bb auth login`"); - } let authorization = HeaderValue::from_str(&format!("BBIdentity {secret}")) .context("stored BuilderBot CLI auth session is invalid; run `bb auth login`")?; Ok(Self { @@ -693,7 +693,7 @@ fn control_plane_http_failure( .unwrap_or("control_plane_request_failed"); let code = credential.redact(&terminal_safe_text(code)); let next_action = if status == StatusCode::UNAUTHORIZED { - Some("Run `bb auth login` to refresh your session.".to_string()) + Some("Run `bb auth logout`, then `bb auth login` to replace your session.".to_string()) } else { parsed .as_ref() @@ -738,7 +738,7 @@ mod tests { #[test] fn compose_session_header_matches_kgoose_contract() { - let secret = "abcdefghijklmnopqrstuvwxyz_ABCDE-1234"; + let secret = "opaque.session+credential/with=punctuation"; let credential = test_credential(secret); assert_eq!( @@ -748,11 +748,7 @@ mod tests { .expect("authorization text"), format!("BBIdentity {secret}") ); - for invalid in [ - "too-short", - "credential with spaces that is long enough", - "credential.with.punctuation.that.is.long", - ] { + for invalid in ["credential\r\nInjected: header", "credential\nheader"] { let error = ComposeSessionCredential::new(invalid.to_string()) .err() .expect("reject invalid session credential"); @@ -943,6 +939,7 @@ mod tests { let message = error.to_string(); assert!(message.contains("401")); + assert!(message.contains("bb auth logout")); assert!(message.contains("bb auth login")); assert!(!message.contains(secret)); server_thread.join().expect("join request server"); diff --git a/bb-cli/src/bb/auth_login.rs b/bb-cli/src/bb/auth_login.rs index 59c4d853b..02c306f62 100644 --- a/bb-cli/src/bb/auth_login.rs +++ b/bb-cli/src/bb/auth_login.rs @@ -18,6 +18,7 @@ use super::auth_storage::{session_storage_key_from_config, SessionCredentialStor use super::skills_config::{kgoose_service_url, SkillsConfig}; const CALLBACK_PATH: &str = "/callback"; +const EMBEDDED_LOGIN_CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60); #[derive(Clone, Copy)] enum AuthCallbackPage { @@ -50,14 +51,20 @@ pub fn run_browser_login( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, ) -> Result { - run_browser_login_with_output(config, storage, BrowserLoginOutput::Standalone) + run_browser_login_with_output(config, storage, BrowserLoginOutput::Standalone, None) } pub fn ensure_browser_login( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, ) -> Result<()> { - run_browser_login_with_output(config, storage, BrowserLoginOutput::Embedded).map(|_| ()) + run_browser_login_with_output( + config, + storage, + BrowserLoginOutput::Embedded, + Some(EMBEDDED_LOGIN_CALLBACK_TIMEOUT), + ) + .map(|_| ()) } #[derive(Clone, Copy)] @@ -111,6 +118,7 @@ fn run_browser_login_with_output( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, output: BrowserLoginOutput, + callback_timeout: Option, ) -> Result { let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); let client = build_auth_http_client(Duration::from_secs(30))?; @@ -183,9 +191,7 @@ fn run_browser_login_with_output( output.browser_fallback(); } - let code = rx - .recv() - .context("loopback auth server stopped before login completed")??; + let code = wait_for_login_callback(rx, callback_timeout)?; let verified = exchange_login_code_and_verify(&client, config.playpen.as_deref(), &service_url, &code)?; let stored = verified.credential; @@ -212,6 +218,25 @@ fn run_browser_login_with_output( }) } +fn wait_for_login_callback( + rx: mpsc::Receiver>, + timeout: Option, +) -> Result { + match timeout { + Some(timeout) => rx.recv_timeout(timeout).map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => { + anyhow!("BuilderBot auth login timed out; run `bb auth login` to try again") + } + mpsc::RecvTimeoutError::Disconnected => { + anyhow!("loopback auth server stopped before login completed") + } + })?, + None => rx + .recv() + .context("loopback auth server stopped before login completed")?, + } +} + pub fn verify_stored_session( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, @@ -367,7 +392,12 @@ fn auth_info(config: &SkillsConfig, message: &str) { #[cfg(test)] mod tests { - use super::{auth_callback_page, AuthCallbackPage, BrowserLoginOutput}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + use super::{ + auth_callback_page, wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, + }; #[test] fn embedded_login_never_writes_command_stdout() { @@ -375,6 +405,18 @@ mod tests { assert!(BrowserLoginOutput::Standalone.writes_command_stdout()); } + #[test] + fn embedded_login_callback_wait_is_bounded() { + let (_tx, rx) = mpsc::channel(); + let started = Instant::now(); + + let error = wait_for_login_callback(rx, Some(Duration::from_millis(10))) + .expect_err("time out abandoned embedded login"); + + assert!(error.to_string().contains("auth login timed out")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + #[test] fn callback_pages_are_self_contained_and_themed() { for page in [AuthCallbackPage::Success, AuthCallbackPage::Failure] { diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 4b7fe6898..cc7359a7f 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -5,8 +5,11 @@ mod common; use std::fs; -use std::io::{Cursor, Write}; +use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::thread; +use std::time::{Duration, Instant}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -3693,6 +3696,64 @@ fn bb_apps_contract_rejects_arbitrary_https_origin() { fs::remove_dir_all(temp).expect("remove temp dir"); } +#[test] +fn bb_apps_json_without_a_session_exits_promptly_with_auth_required() { + let temp = temp_test_dir("bb-apps-json-auth-required"); + let bb_home = temp.join("bb-home"); + let storage_path = temp.join("missing-auth-sessions.json"); + write_bb_org_config(&bb_home, "test"); + + let mut child = bb_command() + .env("BB_HOME", &bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", &storage_path) + .args([ + "apps", + "contract", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + "--json", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start noninteractive bb apps contract"); + let deadline = Instant::now() + Duration::from_secs(2); + let status = loop { + if let Some(status) = child.try_wait().expect("poll bb apps contract") { + break status; + } + if Instant::now() >= deadline { + child.kill().expect("stop hung bb apps contract"); + child.wait().expect("reap hung bb apps contract"); + panic!("bb apps contract did not fail promptly without a session"); + } + thread::sleep(Duration::from_millis(10)); + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + child + .stdout + .take() + .expect("capture stdout") + .read_to_string(&mut stdout) + .expect("read stdout"); + child + .stderr + .take() + .expect("capture stderr") + .read_to_string(&mut stderr) + .expect("read stderr"); + + assert_eq!(status.code(), Some(3), "stderr was: {stderr}"); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("auth_required")); + assert_eq!(payload["error"]["exit_code"], json!(3)); + assert!(!storage_path.exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + // --------------------------------------------------------------------------- // bb tools passthrough From d257c3ec384f5c13d42cfd0af9dc4e6546cb417e Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 18:31:54 -0400 Subject: [PATCH 06/10] Close Compose auth review gaps --- bb-cli/src/bb/apps.rs | 91 +++++---- bb-cli/src/bb/auth_login.rs | 202 +++++++++++++++++--- bb-cli/src/bb/skills.rs | 3 +- bb-cli/tests/bb_e2e.rs | 356 +++++++++++++++++++++++++++++++++++- bb-cli/tests/common/mod.rs | 1 + 5 files changed, 584 insertions(+), 69 deletions(-) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index d1b87b2f7..a9f23ccd9 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -18,6 +18,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use builderbot_auth::auth_login::{auth_url, build_auth_http_client}; +use builderbot_auth::auth_storage::StoredSessionCredential; use clap::{Arg, ArgMatches, Command}; use reqwest::blocking::{multipart, Client, RequestBuilder, Response}; use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; @@ -26,7 +27,7 @@ use serde::Serialize; use serde_json::{json, Map, Value}; use super::auth_login::{ensure_browser_login, verify_stored_session}; -use super::auth_storage::{default_session_storage, session_storage_key_from_config}; +use super::auth_storage::default_session_storage; use super::display::{print_json, stdin_is_tty, terminal_safe_text, Style}; use super::runner; use super::skills_api::{exit_codes, failure}; @@ -34,6 +35,8 @@ use super::skills_config::SkillsConfig; const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; +#[cfg(debug_assertions)] +const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BB_APPS_E2E_CONTROL_PLANE_URL"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; @@ -357,31 +360,16 @@ struct ComposeSessionCredential { impl ComposeSessionCredential { fn from_config(config: &SkillsConfig) -> Result { let storage = default_session_storage(config)?; - let session_storage_key = session_storage_key_from_config(config); if config.json || !stdin_is_tty() { - if verify_stored_session(config, storage.as_ref())?.is_none() { - return Err(auth_required_error()); - } - Self::after_login(storage.as_ref(), &session_storage_key, || Ok(())) + let verified = + verify_stored_session(config, storage.as_ref())?.ok_or_else(auth_required_error)?; + Self::from_stored(verified.credential) } else { - Self::after_login(storage.as_ref(), &session_storage_key, || { - ensure_browser_login(config, storage.as_ref()) - }) + Self::from_stored(ensure_browser_login(config, storage.as_ref())?) } } - fn after_login( - storage: &dyn super::auth_storage::SessionCredentialStorage, - session_storage_key: &super::auth_storage::SessionStorageKey, - login: F, - ) -> Result - where - F: FnOnce() -> Result<()>, - { - login()?; - let credential = storage - .get(session_storage_key)? - .ok_or_else(auth_required_error)?; + fn from_stored(credential: StoredSessionCredential) -> Result { let secret = credential .session_credential_header_value() .ok_or_else(auth_required_error)?; @@ -437,7 +425,8 @@ impl ControlPlaneClient { "Apps Platform control-plane URL must use HTTPS and target an approved Builderlab ingress host" ); } - Self::build(base_url, client_version, style, request_timeout) + let transport_base_url = control_plane_transport_base_url(base_url)?; + Self::build(&transport_base_url, client_version, style, request_timeout) } fn build( @@ -642,6 +631,42 @@ fn is_trusted_control_plane_url(url: &url::Url) -> bool { .any(|trusted| host.eq_ignore_ascii_case(trusted)) } +#[cfg(debug_assertions)] +fn control_plane_transport_base_url(validated_base_url: &str) -> Result { + let Some(override_url) = std::env::var_os(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR) else { + return Ok(validated_base_url.to_string()); + }; + let override_url = override_url + .into_string() + .map_err(|_| anyhow::anyhow!("{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be UTF-8"))?; + let parsed = url::Url::parse(&override_url) + .with_context(|| format!("parse {APPS_E2E_CONTROL_PLANE_URL_ENV_VAR}"))?; + let loopback = match parsed.host() { + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + _ => false, + }; + if parsed.scheme() != "http" + || !loopback + || parsed.port().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + anyhow::bail!( + "{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be an HTTP loopback origin with an explicit port" + ); + } + Ok(override_url.trim_end_matches('/').to_string()) +} + +#[cfg(not(debug_assertions))] +fn control_plane_transport_base_url(validated_base_url: &str) -> Result { + Ok(validated_base_url.to_string()) +} + fn read_limited_response_body( response: reqwest::blocking::Response, max_bytes: usize, @@ -724,10 +749,6 @@ fn control_plane_http_failure( mod tests { use std::thread; - use builderbot_auth::auth_storage::{ - InMemorySessionCredentialStorage, SessionCredentialStorage, SessionStorageKey, - StoredSessionCredential, - }; use tiny_http::{Header, Response, Server}; use super::*; @@ -757,21 +778,13 @@ mod tests { } #[test] - fn compose_session_continues_with_the_session_stored_by_login() { - let storage = InMemorySessionCredentialStorage::default(); - let storage_key = SessionStorageKey::new("default", "https://kgoose.example"); + fn compose_session_uses_the_exact_credential_returned_by_login() { let secret = "session_stored_after_browser_login_12345"; - - let credential = ComposeSessionCredential::after_login(&storage, &storage_key, || { - storage.set( - &storage_key, - &StoredSessionCredential { - session_credential: secret.to_string(), - expires_at: Some("2099-01-01T00:00:00Z".to_string()), - }, - ) + let credential = ComposeSessionCredential::from_stored(StoredSessionCredential { + session_credential: secret.to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), }) - .expect("continue after login"); + .expect("use returned login credential"); assert_eq!( credential diff --git a/bb-cli/src/bb/auth_login.rs b/bb-cli/src/bb/auth_login.rs index 02c306f62..42627133c 100644 --- a/bb-cli/src/bb/auth_login.rs +++ b/bb-cli/src/bb/auth_login.rs @@ -9,6 +9,7 @@ use builderbot_auth::auth_login::{ build_auth_http_client, exchange_login_code_and_verify, login_url, logout_session_credential, verify_session_credential, AuthMeResponse, }; +use builderbot_auth::auth_storage::StoredSessionCredential; use serde::Serialize; use sha2::{Digest, Sha256}; use tiny_http::{Header, Response, Server, StatusCode}; @@ -40,6 +41,16 @@ pub struct BrowserLoginSummary { pub credential_sha256_prefix: Option, } +struct BrowserLoginOutcome { + summary: BrowserLoginSummary, + credential: StoredSessionCredential, +} + +pub struct VerifiedStoredSession { + pub credential: StoredSessionCredential, + pub me: AuthMeResponse, +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub enum BrowserLoginCredentialSource { @@ -52,19 +63,20 @@ pub fn run_browser_login( storage: &dyn SessionCredentialStorage, ) -> Result { run_browser_login_with_output(config, storage, BrowserLoginOutput::Standalone, None) + .map(|outcome| outcome.summary) } pub fn ensure_browser_login( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, -) -> Result<()> { +) -> Result { run_browser_login_with_output( config, storage, BrowserLoginOutput::Embedded, Some(EMBEDDED_LOGIN_CALLBACK_TIMEOUT), ) - .map(|_| ()) + .map(|outcome| outcome.credential) } #[derive(Clone, Copy)] @@ -119,7 +131,7 @@ fn run_browser_login_with_output( storage: &dyn SessionCredentialStorage, output: BrowserLoginOutput, callback_timeout: Option, -) -> Result { +) -> Result { let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); let client = build_auth_http_client(Duration::from_secs(30))?; let storage_key = session_storage_key_from_config(config); @@ -140,15 +152,18 @@ fn run_browser_login_with_output( ), ); let workspace_name = me.active_workspace_name()?.to_string(); - return Ok(BrowserLoginSummary { - kgoose_base_url: config.kgoose_base_url.clone(), - kgoose_service_path: config.kgoose_service_path.clone(), - storage: storage.kind().to_string(), - source: BrowserLoginCredentialSource::Stored, - workspace_name, - expires_at: me.expires_at.or(stored.expires_at), - credential_prefix: None, - credential_sha256_prefix: None, + return Ok(BrowserLoginOutcome { + summary: BrowserLoginSummary { + kgoose_base_url: config.kgoose_base_url.clone(), + kgoose_service_path: config.kgoose_service_path.clone(), + storage: storage.kind().to_string(), + source: BrowserLoginCredentialSource::Stored, + workspace_name, + expires_at: me.expires_at.or_else(|| stored.expires_at.clone()), + credential_prefix: None, + credential_sha256_prefix: None, + }, + credential: stored, }); } output.info( @@ -194,10 +209,9 @@ fn run_browser_login_with_output( let code = wait_for_login_callback(rx, callback_timeout)?; let verified = exchange_login_code_and_verify(&client, config.playpen.as_deref(), &service_url, &code)?; - let stored = verified.credential; + let stored = store_login_credential(storage, &storage_key, verified.credential)?; let me = verified.me; let workspace_name = me.active_workspace_name()?.to_string(); - storage.set(&storage_key, &stored)?; output.info( config, &format!( @@ -206,15 +220,18 @@ fn run_browser_login_with_output( ), ); - Ok(BrowserLoginSummary { - kgoose_base_url: config.kgoose_base_url.clone(), - kgoose_service_path: config.kgoose_service_path.clone(), - storage: storage.kind().to_string(), - source: BrowserLoginCredentialSource::BrowserLogin, - workspace_name, - expires_at: me.expires_at.or_else(|| stored.expires_at.clone()), - credential_prefix: Some(safe_prefix(&stored.session_credential)), - credential_sha256_prefix: Some(sha256_prefix(&stored.session_credential)), + Ok(BrowserLoginOutcome { + summary: BrowserLoginSummary { + kgoose_base_url: config.kgoose_base_url.clone(), + kgoose_service_path: config.kgoose_service_path.clone(), + storage: storage.kind().to_string(), + source: BrowserLoginCredentialSource::BrowserLogin, + workspace_name, + expires_at: me.expires_at.or_else(|| stored.expires_at.clone()), + credential_prefix: Some(safe_prefix(&stored.session_credential)), + credential_sha256_prefix: Some(sha256_prefix(&stored.session_credential)), + }, + credential: stored, }) } @@ -240,15 +257,39 @@ fn wait_for_login_callback( pub fn verify_stored_session( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, -) -> Result> { +) -> Result> { let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); let client = build_auth_http_client(Duration::from_secs(30))?; let storage_key = session_storage_key_from_config(config); - let Some(stored) = storage.get(&storage_key)? else { + verify_stored_session_with(storage, &storage_key, |stored| { + verify_session_credential(&client, config.playpen.as_deref(), &service_url, stored) + }) +} + +fn verify_stored_session_with( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + verify: F, +) -> Result> +where + F: FnOnce(&StoredSessionCredential) -> Result>, +{ + let Some(stored) = storage.get(storage_key)? else { return Ok(None); }; + Ok(verify(&stored)?.map(|me| VerifiedStoredSession { + credential: stored, + me, + })) +} - verify_session_credential(&client, config.playpen.as_deref(), &service_url, &stored) +fn store_login_credential( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + credential: StoredSessionCredential, +) -> Result { + storage.set(storage_key, &credential)?; + Ok(credential) } pub fn logout_stored_session( @@ -392,13 +433,83 @@ fn auth_info(config: &SkillsConfig, message: &str) { #[cfg(test)] mod tests { + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; use std::sync::mpsc; use std::time::{Duration, Instant}; + use anyhow::Result; + use builderbot_auth::auth_login::{AuthMeResponse, AuthMeWorkspace, AuthMeWorkspaces}; + use builderbot_auth::auth_storage::{ + SessionCredentialStorage, SessionStorageKey, StoredSessionCredential, + }; + use super::{ - auth_callback_page, wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, + auth_callback_page, store_login_credential, verify_stored_session_with, + wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, }; + struct SwappingStorage { + reads: RefCell>, + read_count: Cell, + writes: RefCell>, + } + + impl SwappingStorage { + fn new(reads: impl IntoIterator) -> Self { + Self { + reads: RefCell::new(reads.into_iter().collect()), + read_count: Cell::new(0), + writes: RefCell::new(Vec::new()), + } + } + } + + impl SessionCredentialStorage for SwappingStorage { + fn kind(&self) -> &'static str { + "swapping" + } + + fn get(&self, _key: &SessionStorageKey) -> Result> { + self.read_count.set(self.read_count.get() + 1); + Ok(self.reads.borrow_mut().pop_front()) + } + + fn set( + &self, + _key: &SessionStorageKey, + credential: &StoredSessionCredential, + ) -> Result<()> { + self.writes.borrow_mut().push(credential.clone()); + Ok(()) + } + + fn delete(&self, _key: &SessionStorageKey) -> Result { + Ok(false) + } + } + + fn stored(value: &str) -> StoredSessionCredential { + StoredSessionCredential { + session_credential: value.to_string(), + expires_at: None, + } + } + + fn auth_me() -> AuthMeResponse { + AuthMeResponse { + subject: None, + email: None, + name: None, + expires_at: None, + workspaces: AuthMeWorkspaces { + active: vec![AuthMeWorkspace { + name: "Test Workspace".to_string(), + }], + }, + } + } + #[test] fn embedded_login_never_writes_command_stdout() { assert!(!BrowserLoginOutput::Embedded.writes_command_stdout()); @@ -417,6 +528,43 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(1)); } + #[test] + fn verification_returns_the_exact_credential_that_was_checked() { + let storage = SwappingStorage::new([stored("verified-a"), stored("substituted-b")]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + + let verified = verify_stored_session_with(&storage, &key, |credential| { + assert_eq!(credential.session_credential, "verified-a"); + Ok(Some(auth_me())) + }) + .expect("verify stored session") + .expect("verified session"); + + assert_eq!(verified.credential.session_credential, "verified-a"); + assert_eq!(storage.read_count.get(), 1); + assert_eq!( + storage + .get(&key) + .expect("read substituted credential") + .expect("substituted credential") + .session_credential, + "substituted-b" + ); + } + + #[test] + fn interactive_login_completion_returns_issued_credential_without_rereading_storage() { + let storage = SwappingStorage::new([stored("substituted-b")]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + + let returned = store_login_credential(&storage, &key, stored("issued-a")) + .expect("store completed login"); + + assert_eq!(returned.session_credential, "issued-a"); + assert_eq!(storage.read_count.get(), 0); + assert_eq!(storage.writes.borrow()[0].session_credential, "issued-a"); + } + #[test] fn callback_pages_are_self_contained_and_themed() { for page in [AuthCallbackPage::Success, AuthCallbackPage::Failure] { diff --git a/bb-cli/src/bb/skills.rs b/bb-cli/src/bb/skills.rs index 03afa4741..888abed8e 100644 --- a/bb-cli/src/bb/skills.rs +++ b/bb-cli/src/bb/skills.rs @@ -575,7 +575,7 @@ fn config_with_login_org(config: &SkillsConfig) -> Result { fn auth_status(config: &SkillsConfig) -> Result<()> { let storage = default_session_storage(config)?; - let Some(me) = verify_stored_session(config, storage.as_ref())? else { + let Some(verified) = verify_stored_session(config, storage.as_ref())? else { if !config.json { println!("BuilderBot CLI auth"); println!(" profile: {}", config.profile); @@ -591,6 +591,7 @@ fn auth_status(config: &SkillsConfig) -> Result<()> { "profile": config.profile, })); }; + let me = verified.me; if !config.json { let workspace_name = me.active_workspace_name()?; diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index cc7359a7f..33dbcb275 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -7,7 +7,7 @@ mod common; use std::fs; use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::Stdio; +use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -3616,6 +3616,83 @@ fn bb_skills_doctor_offline_reports_server_failure() { // --------------------------------------------------------------------------- // External Apps Platform control plane +const APPROVED_APPS_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + +fn apps_auth_me_response() -> MockResponse { + MockResponse::json(json!({ + "subject": "auth0|apps-user", + "email": "apps@example.com", + "name": "Apps User", + "expires_at": "2099-01-01T00:00:00Z", + "workspaces": {"active": [{"name": "Test Workspace"}]} + })) +} + +fn configured_apps_command(server: &MockServer, temp: &Path, credential: &str) -> Command { + let bb_home = temp.join("bb-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bb_org_config(&bb_home, "test"); + write_browser_auth_session( + &storage_path, + &server.base_url, + credential, + "2099-01-01T00:00:00Z", + ); + + let mut command = bb_command(); + command + .env("BB_HOME", bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .env("BB_APPS_E2E_CONTROL_PLANE_URL", &server.base_url); + command +} + +fn assert_apps_auth_request(request: &common::RecordedRequest, credential: &str) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/api/goose/v1/auth/me"); + assert_eq!( + request + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(credential) + ); + assert!(!request.headers.contains_key("authorization")); +} + +fn assert_apps_control_plane_request( + request: &common::RecordedRequest, + method: &str, + path: &str, + credential: &str, + client_version: &str, +) { + let expected_authorization = format!("BBIdentity {credential}"); + assert_eq!(request.method, method); + assert_eq!(request.path, path); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(expected_authorization.as_str()) + ); + assert_eq!( + request + .headers + .get("x-hotpod-agent-client-version") + .map(String::as_str), + Some(client_version) + ); + for forbidden in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-user", + "x-forwarded-workspace-id", + ] { + assert!(!request.headers.contains_key(forbidden)); + } +} + #[test] fn bb_apps_help_distinguishes_external_and_internal_paths() { let output = bb_command() @@ -3640,6 +3717,281 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } } +#[test] +fn bb_apps_contract_verifies_and_uses_the_same_session() { + let credential = "contract.session+credential"; + let contract = json!({ + "ok": true, + "contract_version": "2026-06-30", + "supported_operations": [{"method": "GET", "path": "/v1/agent/contract"}] + }); + let server = MockServer::start(vec![ + apps_auth_me_response(), + MockResponse::json(contract.clone()), + ]); + let temp = temp_test_dir("bb-apps-contract-success"); + + let output = configured_apps_command(&server, &temp, credential) + .args([ + "apps", + "contract", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps contract"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse contract output"), + contract + ); + assert!(!stdout.contains(credential)); + assert!(!stderr.contains(credential)); + assert_eq!(requests.len(), 2); + assert_apps_auth_request(&requests[0], credential); + assert_apps_control_plane_request( + &requests[1], + "GET", + "/v1/agent/contract", + credential, + "0.2.0", + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bb_apps_create_runs_plan_and_initialize_end_to_end() { + let credential = "create.session+credential"; + let plan = json!({ + "ok": true, + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "ok": true, + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2--bpsites.example/" + }); + let server = MockServer::start(vec![ + apps_auth_me_response(), + MockResponse::json(plan.clone()), + MockResponse::json(initialized.clone()), + ]); + let temp = temp_test_dir("bb-apps-create-success"); + + let output = configured_apps_command(&server, &temp, credential) + .args([ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + "fetch-js", + "--persistence", + "sqlite", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps create"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse create output"); + assert_eq!(response["app_id"], json!("merchant-lookup-2")); + assert_eq!(response["initialized"], json!(true)); + assert_eq!(response["plan"], plan); + assert_eq!(response["initialize"], initialized); + assert!(!stdout.contains(credential)); + assert!(!stderr.contains(credential)); + assert_eq!(requests.len(), 3); + assert_apps_auth_request(&requests[0], credential); + assert_apps_control_plane_request( + &requests[1], + "POST", + "/v1/agent/apps/plan", + credential, + "0.2.0", + ); + assert_eq!( + requests[1].body, + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": "fetch-js", + "persistence": "sqlite", + "client_version": "0.2.0" + }) + ); + assert_apps_control_plane_request( + &requests[2], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + "0.2.0", + ); + assert_eq!( + requests[2].body, + json!({ + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default" + }) + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bb_apps_create_skips_initialize_when_plan_does_not_request_it() { + let credential = "existing.session+credential"; + let plan = json!({ + "ok": true, + "app_id": "existing-app", + "external_url": "https://existing-app--bpsites.example/", + "initialize": {"required": false, "recommended": false} + }); + let server = MockServer::start(vec![ + apps_auth_me_response(), + MockResponse::json(plan.clone()), + ]); + let temp = temp_test_dir("bb-apps-create-existing-success"); + + let output = configured_apps_command(&server, &temp, credential) + .args([ + "apps", + "create", + "--app-id", + "existing-app", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps create without initialize"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse create output"); + assert_eq!(response["app_id"], json!("existing-app")); + assert_eq!(response["initialized"], json!(false)); + assert_eq!(response["initialize"], Value::Null); + assert_eq!(response["plan"], plan); + assert_eq!(requests.len(), 2); + assert_apps_auth_request(&requests[0], credential); + assert_apps_control_plane_request( + &requests[1], + "POST", + "/v1/agent/apps/plan", + credential, + "0.2.0", + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bb_apps_deploy_uploads_multipart_artifact_end_to_end() { + let credential = "deploy.session+credential"; + let deployed = json!({ + "ok": true, + "app_id": "merchant-lookup", + "version_id": "ver-123", + "deployment_id": "dpl-123", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let server = MockServer::start(vec![ + apps_auth_me_response(), + MockResponse::json(deployed.clone()), + ]); + let temp = temp_test_dir("bb-apps-deploy-success"); + let artifact_path = temp.join("prepared-app.tar.gz"); + let artifact_marker = "test-hotpod-artifact-marker"; + fs::write(&artifact_path, artifact_marker).expect("write deploy artifact"); + + let output = configured_apps_command(&server, &temp, credential) + .args([ + "apps", + "deploy", + "merchant-lookup", + artifact_path.to_str().expect("artifact path text"), + "--environment", + "production", + "--version-id", + "ver-123", + "--deployment-id", + "dpl-123", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps deploy"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse deploy output"), + deployed + ); + assert!(!stdout.contains(credential)); + assert!(!stderr.contains(credential)); + assert_eq!(requests.len(), 2); + assert_apps_auth_request(&requests[0], credential); + let request = &requests[1]; + assert_apps_control_plane_request( + request, + "POST", + "/v1/agent/apps/merchant-lookup/deploy", + credential, + "0.2.0", + ); + assert!(request + .headers + .get("content-type") + .is_some_and(|value| value.starts_with("multipart/form-data; boundary="))); + let body = String::from_utf8_lossy(&request.body_bytes); + for expected in [ + "name=\"artifact\"; filename=\"artifact.tar.gz\"", + "Content-Type: application/gzip", + artifact_marker, + "name=\"environment\"\r\n\r\nproduction", + "name=\"version_id\"\r\n\r\nver-123", + "name=\"deployment_id\"\r\n\r\ndpl-123", + ] { + assert!( + body.contains(expected), + "multipart body omitted {expected:?}" + ); + } + assert!(!body.contains("publisher")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + #[test] fn bb_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { let kgoose = MockServer::start(vec![]); @@ -3718,7 +4070,7 @@ fn bb_apps_json_without_a_session_exits_promptly_with_auth_required() { .stderr(Stdio::piped()) .spawn() .expect("start noninteractive bb apps contract"); - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + Duration::from_secs(10); let status = loop { if let Some(status) = child.try_wait().expect("poll bb apps contract") { break status; diff --git a/bb-cli/tests/common/mod.rs b/bb-cli/tests/common/mod.rs index 3c99f5861..13aabc882 100644 --- a/bb-cli/tests/common/mod.rs +++ b/bb-cli/tests/common/mod.rs @@ -197,6 +197,7 @@ pub fn bb_command() -> Command { .env_remove("BB_KGOOSE_PLAYPEN") .env_remove("BB_AUTH_STORAGE") .env_remove("BB_AUTH_STORAGE_FILE") + .env_remove("BB_APPS_E2E_CONTROL_PLANE_URL") .env_remove("KGOOSE_BASE_URL") .env_remove("KGOOSE_DEBUG") .env_remove("KGOOSE_PLAYPEN") From 46ad0138e1d9abe4cff15c5291fd1af44a6caad0 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 20:25:13 -0400 Subject: [PATCH 07/10] Keep Compose credentials on approved transports --- bb-cli/src/bb/apps.rs | 471 +++++++++++++++++++++++++++--------- bb-cli/src/bb/auth_login.rs | 48 +++- bb-cli/tests/bb_e2e.rs | 354 +-------------------------- bb-cli/tests/common/mod.rs | 1 - 4 files changed, 396 insertions(+), 478 deletions(-) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index a9f23ccd9..46af4be52 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -20,7 +20,7 @@ use anyhow::{Context, Result}; use builderbot_auth::auth_login::{auth_url, build_auth_http_client}; use builderbot_auth::auth_storage::StoredSessionCredential; use clap::{Arg, ArgMatches, Command}; -use reqwest::blocking::{multipart, Client, RequestBuilder, Response}; +use reqwest::blocking::{multipart, Client, Request, RequestBuilder, Response}; use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; use reqwest::StatusCode; use serde::Serialize; @@ -35,8 +35,6 @@ use super::skills_config::SkillsConfig; const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; -#[cfg(debug_assertions)] -const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BB_APPS_E2E_CONTROL_PLANE_URL"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; @@ -202,6 +200,14 @@ fn run_contract(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let (client, credential) = control_plane_context(config, matches)?; + print_json(&create_response(&client, &credential, matches)?) +} + +fn create_response( + client: &ControlPlaneClient, + credential: &ComposeSessionCredential, + matches: &ArgMatches, +) -> Result { let request = PlanRequest { app_id: matches.get_one::("app-id").map(String::as_str), name: matches.get_one::("name").map(String::as_str), @@ -212,7 +218,7 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { persistence: matches.get_one::("persistence").map(String::as_str), client_version: client.client_version_text(), }; - let plan = client.plan(&credential, &request)?; + let plan = client.plan(credential, &request)?; let app_id = required_response_string(&plan, "app_id", "Apps Platform plan")?.to_string(); let initialize_required = plan .pointer("/initialize/required") @@ -229,7 +235,7 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { initialize_required.unwrap_or(false) || initialize_recommended.unwrap_or(false); let initialize = if should_initialize { let request = initialize_request_from_plan(&plan); - Some(client.initialize(&credential, &app_id, &request)?) + Some(client.initialize(credential, &app_id, &request)?) } else { None }; @@ -246,7 +252,7 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { plan.get("external_url").cloned().unwrap_or(Value::Null), ), }; - print_json(&json!({ + Ok(json!({ "ok": true, "app_id": effective_app_id, "external_url": effective_external_url, @@ -257,6 +263,19 @@ fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { } fn run_deploy(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let artifact = matches + .get_one::("artifact") + .context("expected artifact.tar.gz path")?; + validate_artifact_path(artifact)?; + let (client, credential) = control_plane_context(config, matches)?; + print_json(&deploy_response(&client, &credential, matches)?) +} + +fn deploy_response( + client: &ControlPlaneClient, + credential: &ComposeSessionCredential, + matches: &ArgMatches, +) -> Result { let artifact = matches .get_one::("artifact") .context("expected artifact.tar.gz path")?; @@ -269,9 +288,7 @@ fn run_deploy(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { version_id: matches.get_one::("version-id").cloned(), deployment_id: matches.get_one::("deployment-id").cloned(), }; - let (client, credential) = control_plane_context(config, matches)?; - let response = client.deploy(&credential, app_id, artifact, &options)?; - print_json(&response) + client.deploy(credential, app_id, artifact, &options) } fn control_plane_context( @@ -396,12 +413,19 @@ impl ComposeSessionCredential { struct ControlPlaneClient { client: Client, + #[cfg(test)] + test_transport: Option>, base_url: String, client_version: HeaderValue, client_version_text: String, style: Style, } +#[cfg(test)] +trait ControlPlaneTransport { + fn execute(&self, request: Request) -> reqwest::Result; +} + impl ControlPlaneClient { fn new(base_url: &str, client_version: &str, style: Style) -> Result { Self::new_with_timeout( @@ -418,28 +442,19 @@ impl ControlPlaneClient { style: Style, request_timeout: Duration, ) -> Result { - let contract_url = auth_url(base_url, APPS_CONTRACT_PATH) - .context("build Apps Platform control-plane contract URL")?; - if !is_trusted_control_plane_url(&contract_url) { - anyhow::bail!( - "Apps Platform control-plane URL must use HTTPS and target an approved Builderlab ingress host" - ); - } - let transport_base_url = control_plane_transport_base_url(base_url)?; - Self::build(&transport_base_url, client_version, style, request_timeout) + validate_control_plane_base_url(base_url)?; + let client = build_auth_http_client(request_timeout)?; + Self::build(base_url, client_version, style, client) } - fn build( - base_url: &str, - client_version: &str, - style: Style, - request_timeout: Duration, - ) -> Result { + fn build(base_url: &str, client_version: &str, style: Style, client: Client) -> Result { let client_version_text = client_version.to_string(); let client_version = HeaderValue::from_str(client_version) .context("Apps Platform client version is not a valid HTTP header value")?; Ok(Self { - client: build_auth_http_client(request_timeout)?, + client, + #[cfg(test)] + test_transport: None, base_url: base_url.to_string(), client_version, client_version_text, @@ -453,8 +468,17 @@ impl ControlPlaneClient { client_version: &str, style: Style, request_timeout: Duration, + transport: Box, ) -> Result { - Self::build(base_url, client_version, style, request_timeout) + validate_control_plane_base_url(base_url)?; + let mut client = Self::build( + base_url, + client_version, + style, + build_auth_http_client(request_timeout)?, + )?; + client.test_transport = Some(transport); + Ok(client) } fn client_version_text(&self) -> &str { @@ -465,8 +489,8 @@ impl ControlPlaneClient { let url = self.endpoint(APPS_CONTRACT_PATH)?; self.authorized_json_request(credential, "GET", APPS_CONTRACT_PATH, |authorization| { self.standard_request(self.client.get(url.clone()), authorization) - .send() - .map_err(|error| network_failure("GET", APPS_CONTRACT_PATH, error)) + .build() + .context("build Apps Platform contract request") }) } @@ -479,8 +503,8 @@ impl ControlPlaneClient { self.authorized_json_request(credential, "POST", APPS_PLAN_PATH, |authorization| { self.standard_request(self.client.post(url.clone()), authorization) .json(request) - .send() - .map_err(|error| network_failure("POST", APPS_PLAN_PATH, error)) + .build() + .context("build Apps Platform plan request") }) } @@ -495,8 +519,8 @@ impl ControlPlaneClient { self.authorized_json_request(credential, "POST", &path, |authorization| { self.standard_request(self.client.post(url.clone()), authorization) .json(request) - .send() - .map_err(|error| network_failure("POST", &path, error)) + .build() + .context("build Apps Platform initialize request") }) } @@ -513,8 +537,8 @@ impl ControlPlaneClient { let form = deploy_form(artifact, options)?; self.standard_request(self.client.post(url.clone()), authorization) .multipart(form) - .send() - .map_err(|error| network_failure("POST", &path, error)) + .build() + .context("build Apps Platform deploy request") }) } @@ -557,7 +581,7 @@ impl ControlPlaneClient { send: F, ) -> Result where - F: Fn(HeaderValue) -> Result, + F: Fn(HeaderValue) -> Result, { let authorization = credential.authorization_header(); let (status, body) = self.request_response(method, path, &send, authorization)?; @@ -578,10 +602,13 @@ impl ControlPlaneClient { authorization: HeaderValue, ) -> Result<(StatusCode, String)> where - F: Fn(HeaderValue) -> Result, + F: Fn(HeaderValue) -> Result, { self.style.verbose(&format!("{method} {path}")); - let response = send(authorization)?; + let request = send(authorization)?; + let response = self + .execute_request(request) + .map_err(|error| network_failure(method, path, error))?; let status = response.status(); let body = read_limited_response_body( response, @@ -594,6 +621,14 @@ impl ControlPlaneClient { )); Ok((status, body)) } + + fn execute_request(&self, request: Request) -> reqwest::Result { + #[cfg(test)] + if let Some(transport) = self.test_transport.as_ref() { + return transport.execute(request); + } + self.client.execute(request) + } } fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result { @@ -631,40 +666,15 @@ fn is_trusted_control_plane_url(url: &url::Url) -> bool { .any(|trusted| host.eq_ignore_ascii_case(trusted)) } -#[cfg(debug_assertions)] -fn control_plane_transport_base_url(validated_base_url: &str) -> Result { - let Some(override_url) = std::env::var_os(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR) else { - return Ok(validated_base_url.to_string()); - }; - let override_url = override_url - .into_string() - .map_err(|_| anyhow::anyhow!("{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be UTF-8"))?; - let parsed = url::Url::parse(&override_url) - .with_context(|| format!("parse {APPS_E2E_CONTROL_PLANE_URL_ENV_VAR}"))?; - let loopback = match parsed.host() { - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - _ => false, - }; - if parsed.scheme() != "http" - || !loopback - || parsed.port().is_none() - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.path() != "/" - || parsed.query().is_some() - || parsed.fragment().is_some() - { +fn validate_control_plane_base_url(base_url: &str) -> Result<()> { + let contract_url = auth_url(base_url, APPS_CONTRACT_PATH) + .context("build Apps Platform control-plane contract URL")?; + if !is_trusted_control_plane_url(&contract_url) { anyhow::bail!( - "{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be an HTTP loopback origin with an explicit port" + "Apps Platform control-plane URL must use HTTPS and target an approved Builderlab ingress host" ); } - Ok(override_url.trim_end_matches('/').to_string()) -} - -#[cfg(not(debug_assertions))] -fn control_plane_transport_base_url(validated_base_url: &str) -> Result { - Ok(validated_base_url.to_string()) + Ok(()) } fn read_limited_response_body( @@ -753,6 +763,65 @@ mod tests { use super::*; + const APPROVED_TEST_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + + struct LoopbackTestTransport { + client: Client, + base_url: url::Url, + } + + impl LoopbackTestTransport { + fn new(base_url: &str, timeout: Duration) -> Self { + Self { + client: build_auth_http_client(timeout).expect("build test transport client"), + base_url: url::Url::parse(base_url).expect("parse test transport URL"), + } + } + } + + impl ControlPlaneTransport for LoopbackTestTransport { + fn execute(&self, mut request: Request) -> reqwest::Result { + assert!( + is_trusted_control_plane_url(request.url()), + "request must retain its approved production URL until transport execution: {}", + request.url() + ); + let query = request.url().query().map(str::to_string); + let mut loopback_url = self.base_url.clone(); + loopback_url.set_path(request.url().path()); + loopback_url.set_query(query.as_deref()); + *request.url_mut() = loopback_url; + self.client.execute(request) + } + } + + fn test_control_plane_client(base_url: &str, timeout: Duration) -> ControlPlaneClient { + ControlPlaneClient::new_for_test( + APPROVED_TEST_BASE_URL, + "1.0.0", + Style::new(true, false, false), + timeout, + Box::new(LoopbackTestTransport::new(base_url, timeout)), + ) + .expect("build test control-plane client") + } + + fn parsed_apps_subcommand(args: &[&str]) -> ArgMatches { + command() + .try_get_matches_from(args) + .expect("parse Apps command") + .subcommand() + .expect("Apps subcommand") + .1 + .clone() + } + + fn json_response(value: &Value) -> Response>> { + Response::from_string(value.to_string()).with_header( + Header::from_bytes("Content-Type", "application/json").expect("build content type"), + ) + } + fn test_credential(secret: &str) -> ComposeSessionCredential { ComposeSessionCredential::new(secret.to_string()).expect("build test credential") } @@ -795,6 +864,216 @@ mod tests { ); } + #[test] + fn create_command_runs_plan_and_required_initialize() { + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2.example" + }); + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn({ + let plan = plan.clone(); + let initialized = initialized.clone(); + move || { + let mut request = server.recv().expect("receive plan request"); + assert_eq!(request.url(), APPS_PLAN_PATH); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read plan request"); + assert_eq!( + serde_json::from_str::(&body).expect("parse plan request"), + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": "fetch-js", + "persistence": "sqlite", + "client_version": "1.0.0" + }) + ); + request + .respond(json_response(&plan)) + .expect("respond to plan"); + + let mut request = server.recv().expect("receive initialize request"); + assert_eq!(request.url(), "/v1/agent/apps/merchant-lookup/initialize"); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read initialize request"); + assert_eq!( + serde_json::from_str::(&body).expect("parse initialize request"), + json!({ + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default" + }) + ); + request + .respond(json_response(&initialized)) + .expect("respond to initialize"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let matches = parsed_apps_subcommand(&[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + "fetch-js", + "--persistence", + "sqlite", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "1.0.0", + ]); + + let response = create_response( + &client, + &test_credential("create_session_credential_123456"), + &matches, + ) + .expect("create app"); + + assert_eq!(response["app_id"], "merchant-lookup-2"); + assert_eq!(response["initialized"], true); + assert_eq!(response["plan"], plan); + assert_eq!(response["initialize"], initialized); + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn create_command_skips_initialize_when_plan_does_not_request_it() { + let plan = json!({ + "app_id": "existing-app", + "external_url": "https://existing-app.example", + "initialize": {"required": false, "recommended": false} + }); + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn({ + let plan = plan.clone(); + move || { + let request = server.recv().expect("receive plan request"); + assert_eq!(request.url(), APPS_PLAN_PATH); + request + .respond(json_response(&plan)) + .expect("respond to plan"); + assert!(server + .recv_timeout(Duration::from_millis(250)) + .expect("wait for unexpected initialize") + .is_none()); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let matches = parsed_apps_subcommand(&[ + "apps", + "create", + "--app-id", + "existing-app", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "1.0.0", + ]); + + let response = create_response( + &client, + &test_credential("existing_session_credential_123456"), + &matches, + ) + .expect("reuse existing app"); + + assert_eq!(response["app_id"], "existing-app"); + assert_eq!(response["initialized"], false); + assert!(response["initialize"].is_null()); + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn deploy_command_uploads_the_parsed_artifact_and_options() { + let temporary_directory = tempfile::tempdir().expect("create temporary directory"); + let artifact_path = temporary_directory.path().join("artifact.tar.gz"); + fs::write(&artifact_path, b"test-deploy-artifact").expect("write artifact"); + let deployed = json!({"ok": true, "version_id": "ver-123"}); + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn({ + let deployed = deployed.clone(); + move || { + let mut request = server.recv().expect("receive deploy request"); + assert_eq!(request.url(), "/v1/agent/apps/merchant-lookup/deploy"); + let mut body = Vec::new(); + request + .as_reader() + .read_to_end(&mut body) + .expect("read deploy request"); + let body = String::from_utf8_lossy(&body); + for expected in [ + "test-deploy-artifact", + "name=\"environment\"\r\n\r\nproduction", + "name=\"version_id\"\r\n\r\nver-123", + "name=\"deployment_id\"\r\n\r\ndpl-123", + ] { + assert!( + body.contains(expected), + "multipart body omitted {expected:?}" + ); + } + request + .respond(json_response(&deployed)) + .expect("respond to deploy"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let artifact = artifact_path.to_str().expect("artifact path"); + let matches = parsed_apps_subcommand(&[ + "apps", + "deploy", + "merchant-lookup", + artifact, + "--environment", + "production", + "--version-id", + "ver-123", + "--deployment-id", + "dpl-123", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "1.0.0", + ]); + + let response = deploy_response( + &client, + &test_credential("deploy_session_credential_123456"), + &matches, + ) + .expect("deploy app"); + + assert_eq!(response, deployed); + server_thread.join().expect("join control-plane server"); + } + #[test] fn control_plane_allowlist_is_exact_and_https_only() { let style = Style::new(true, false, false); @@ -871,13 +1150,7 @@ mod tests { ) .expect("respond to contract request"); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build test control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let contract = client .contract(&test_credential(secret)) @@ -901,13 +1174,7 @@ mod tests { )) .expect("send redirect"); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build test control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let secret = "redirect_session_credential_123456"; let error = client @@ -937,13 +1204,7 @@ mod tests { .expect("wait for unexpected retry") .is_none()); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build test control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let secret = "expired_session_credential_1234567"; let error = client @@ -974,13 +1235,7 @@ mod tests { .respond(Response::from_string(response_body).with_status_code(400)) .expect("send reflected error"); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build test control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let error = client .contract(&test_credential(secret)) @@ -1037,13 +1292,7 @@ mod tests { ) .expect("respond to deploy request"); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(1), - ) - .expect("build control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(1)); let initialized = client .initialize( @@ -1080,13 +1329,7 @@ mod tests { ])) .expect("respond with oversized plan response"); }); - let client = ControlPlaneClient::new_for_test( - &base_url, - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build test control-plane client"); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); let request = PlanRequest { app_id: Some("bounded-app"), name: None, @@ -1112,13 +1355,7 @@ mod tests { #[test] fn app_ids_are_encoded_as_single_path_segments() { - let client = ControlPlaneClient::new_for_test( - "http://127.0.0.1:9", - "1.0.0", - Style::new(true, false, false), - Duration::from_secs(2), - ) - .expect("build control-plane client"); + let client = test_control_plane_client("http://127.0.0.1:9", Duration::from_secs(2)); let url = client .app_action_url("app/../../identity", "deploy") diff --git a/bb-cli/src/bb/auth_login.rs b/bb-cli/src/bb/auth_login.rs index 42627133c..fce1ec1bd 100644 --- a/bb-cli/src/bb/auth_login.rs +++ b/bb-cli/src/bb/auth_login.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{anyhow, Context, Result}; use builderbot_auth::auth_login::{ build_auth_http_client, exchange_login_code_and_verify, login_url, logout_session_credential, - verify_session_credential, AuthMeResponse, + verify_session_credential, AuthMeResponse, VerifiedLoginSession, }; use builderbot_auth::auth_storage::StoredSessionCredential; use serde::Serialize; @@ -209,9 +209,8 @@ fn run_browser_login_with_output( let code = wait_for_login_callback(rx, callback_timeout)?; let verified = exchange_login_code_and_verify(&client, config.playpen.as_deref(), &service_url, &code)?; - let stored = store_login_credential(storage, &storage_key, verified.credential)?; - let me = verified.me; - let workspace_name = me.active_workspace_name()?.to_string(); + let (stored, me, workspace_name) = + validate_and_store_login_credential(storage, &storage_key, verified)?; output.info( config, &format!( @@ -292,6 +291,16 @@ fn store_login_credential( Ok(credential) } +fn validate_and_store_login_credential( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + verified: VerifiedLoginSession, +) -> Result<(StoredSessionCredential, AuthMeResponse, String)> { + let workspace_name = verified.me.active_workspace_name()?.to_string(); + let stored = store_login_credential(storage, storage_key, verified.credential)?; + Ok((stored, verified.me, workspace_name)) +} + pub fn logout_stored_session( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, @@ -439,14 +448,16 @@ mod tests { use std::time::{Duration, Instant}; use anyhow::Result; - use builderbot_auth::auth_login::{AuthMeResponse, AuthMeWorkspace, AuthMeWorkspaces}; + use builderbot_auth::auth_login::{ + AuthMeResponse, AuthMeWorkspace, AuthMeWorkspaces, VerifiedLoginSession, + }; use builderbot_auth::auth_storage::{ SessionCredentialStorage, SessionStorageKey, StoredSessionCredential, }; use super::{ - auth_callback_page, store_login_credential, verify_stored_session_with, - wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, + auth_callback_page, store_login_credential, validate_and_store_login_credential, + verify_stored_session_with, wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, }; struct SwappingStorage { @@ -565,6 +576,29 @@ mod tests { assert_eq!(storage.writes.borrow()[0].session_credential, "issued-a"); } + #[test] + fn browser_login_does_not_store_a_session_without_an_active_workspace() { + let storage = SwappingStorage::new([]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + let verified = VerifiedLoginSession { + credential: stored("issued-without-workspace"), + me: AuthMeResponse { + subject: None, + email: None, + name: None, + expires_at: None, + workspaces: AuthMeWorkspaces { active: vec![] }, + }, + }; + + let error = validate_and_store_login_credential(&storage, &key, verified) + .expect_err("reject login without an active workspace"); + + assert!(error.to_string().contains("no active workspaces")); + assert!(storage.writes.borrow().is_empty()); + assert!(storage.get(&key).expect("read storage").is_none()); + } + #[test] fn callback_pages_are_self_contained_and_themed() { for page in [AuthCallbackPage::Success, AuthCallbackPage::Failure] { diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 33dbcb275..dc77068bb 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -7,7 +7,7 @@ mod common; use std::fs; use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::thread; use std::time::{Duration, Instant}; @@ -3616,83 +3616,6 @@ fn bb_skills_doctor_offline_reports_server_failure() { // --------------------------------------------------------------------------- // External Apps Platform control plane -const APPROVED_APPS_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; - -fn apps_auth_me_response() -> MockResponse { - MockResponse::json(json!({ - "subject": "auth0|apps-user", - "email": "apps@example.com", - "name": "Apps User", - "expires_at": "2099-01-01T00:00:00Z", - "workspaces": {"active": [{"name": "Test Workspace"}]} - })) -} - -fn configured_apps_command(server: &MockServer, temp: &Path, credential: &str) -> Command { - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &server.base_url, - credential, - "2099-01-01T00:00:00Z", - ); - - let mut command = bb_command(); - command - .env("BB_HOME", bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", storage_path) - .env("KGOOSE_BASE_URL", &server.base_url) - .env("BB_APPS_E2E_CONTROL_PLANE_URL", &server.base_url); - command -} - -fn assert_apps_auth_request(request: &common::RecordedRequest, credential: &str) { - assert_eq!(request.method, "GET"); - assert_eq!(request.path, "/api/goose/v1/auth/me"); - assert_eq!( - request - .headers - .get("x-bb-session-credential") - .map(String::as_str), - Some(credential) - ); - assert!(!request.headers.contains_key("authorization")); -} - -fn assert_apps_control_plane_request( - request: &common::RecordedRequest, - method: &str, - path: &str, - credential: &str, - client_version: &str, -) { - let expected_authorization = format!("BBIdentity {credential}"); - assert_eq!(request.method, method); - assert_eq!(request.path, path); - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some(expected_authorization.as_str()) - ); - assert_eq!( - request - .headers - .get("x-hotpod-agent-client-version") - .map(String::as_str), - Some(client_version) - ); - for forbidden in [ - "cookie", - "x-bb-session-credential", - "x-forwarded-user", - "x-forwarded-workspace-id", - ] { - assert!(!request.headers.contains_key(forbidden)); - } -} - #[test] fn bb_apps_help_distinguishes_external_and_internal_paths() { let output = bb_command() @@ -3717,281 +3640,6 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } } -#[test] -fn bb_apps_contract_verifies_and_uses_the_same_session() { - let credential = "contract.session+credential"; - let contract = json!({ - "ok": true, - "contract_version": "2026-06-30", - "supported_operations": [{"method": "GET", "path": "/v1/agent/contract"}] - }); - let server = MockServer::start(vec![ - apps_auth_me_response(), - MockResponse::json(contract.clone()), - ]); - let temp = temp_test_dir("bb-apps-contract-success"); - - let output = configured_apps_command(&server, &temp, credential) - .args([ - "apps", - "contract", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps contract"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse contract output"), - contract - ); - assert!(!stdout.contains(credential)); - assert!(!stderr.contains(credential)); - assert_eq!(requests.len(), 2); - assert_apps_auth_request(&requests[0], credential); - assert_apps_control_plane_request( - &requests[1], - "GET", - "/v1/agent/contract", - credential, - "0.2.0", - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_create_runs_plan_and_initialize_end_to_end() { - let credential = "create.session+credential"; - let plan = json!({ - "ok": true, - "app_id": "merchant-lookup", - "display_name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default", - "initialize": {"required": true, "recommended": false} - }); - let initialized = json!({ - "ok": true, - "app_id": "merchant-lookup-2", - "external_url": "https://merchant-lookup-2--bpsites.example/" - }); - let server = MockServer::start(vec![ - apps_auth_me_response(), - MockResponse::json(plan.clone()), - MockResponse::json(initialized.clone()), - ]); - let temp = temp_test_dir("bb-apps-create-success"); - - let output = configured_apps_command(&server, &temp, credential) - .args([ - "apps", - "create", - "--app-id", - "merchant-lookup", - "--name", - "Merchant Lookup", - "--environment", - "staging", - "--runtime-profile", - "fetch-js", - "--persistence", - "sqlite", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps create"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["app_id"], json!("merchant-lookup-2")); - assert_eq!(response["initialized"], json!(true)); - assert_eq!(response["plan"], plan); - assert_eq!(response["initialize"], initialized); - assert!(!stdout.contains(credential)); - assert!(!stderr.contains(credential)); - assert_eq!(requests.len(), 3); - assert_apps_auth_request(&requests[0], credential); - assert_apps_control_plane_request( - &requests[1], - "POST", - "/v1/agent/apps/plan", - credential, - "0.2.0", - ); - assert_eq!( - requests[1].body, - json!({ - "app_id": "merchant-lookup", - "name": "Merchant Lookup", - "environment": "staging", - "runtime_profile": "fetch-js", - "persistence": "sqlite", - "client_version": "0.2.0" - }) - ); - assert_apps_control_plane_request( - &requests[2], - "POST", - "/v1/agent/apps/merchant-lookup/initialize", - credential, - "0.2.0", - ); - assert_eq!( - requests[2].body, - json!({ - "name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default" - }) - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_create_skips_initialize_when_plan_does_not_request_it() { - let credential = "existing.session+credential"; - let plan = json!({ - "ok": true, - "app_id": "existing-app", - "external_url": "https://existing-app--bpsites.example/", - "initialize": {"required": false, "recommended": false} - }); - let server = MockServer::start(vec![ - apps_auth_me_response(), - MockResponse::json(plan.clone()), - ]); - let temp = temp_test_dir("bb-apps-create-existing-success"); - - let output = configured_apps_command(&server, &temp, credential) - .args([ - "apps", - "create", - "--app-id", - "existing-app", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps create without initialize"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["app_id"], json!("existing-app")); - assert_eq!(response["initialized"], json!(false)); - assert_eq!(response["initialize"], Value::Null); - assert_eq!(response["plan"], plan); - assert_eq!(requests.len(), 2); - assert_apps_auth_request(&requests[0], credential); - assert_apps_control_plane_request( - &requests[1], - "POST", - "/v1/agent/apps/plan", - credential, - "0.2.0", - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[test] -fn bb_apps_deploy_uploads_multipart_artifact_end_to_end() { - let credential = "deploy.session+credential"; - let deployed = json!({ - "ok": true, - "app_id": "merchant-lookup", - "version_id": "ver-123", - "deployment_id": "dpl-123", - "external_url": "https://merchant-lookup--bpsites.example/" - }); - let server = MockServer::start(vec![ - apps_auth_me_response(), - MockResponse::json(deployed.clone()), - ]); - let temp = temp_test_dir("bb-apps-deploy-success"); - let artifact_path = temp.join("prepared-app.tar.gz"); - let artifact_marker = "test-hotpod-artifact-marker"; - fs::write(&artifact_path, artifact_marker).expect("write deploy artifact"); - - let output = configured_apps_command(&server, &temp, credential) - .args([ - "apps", - "deploy", - "merchant-lookup", - artifact_path.to_str().expect("artifact path text"), - "--environment", - "production", - "--version-id", - "ver-123", - "--deployment-id", - "dpl-123", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps deploy"); - let requests = server.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse deploy output"), - deployed - ); - assert!(!stdout.contains(credential)); - assert!(!stderr.contains(credential)); - assert_eq!(requests.len(), 2); - assert_apps_auth_request(&requests[0], credential); - let request = &requests[1]; - assert_apps_control_plane_request( - request, - "POST", - "/v1/agent/apps/merchant-lookup/deploy", - credential, - "0.2.0", - ); - assert!(request - .headers - .get("content-type") - .is_some_and(|value| value.starts_with("multipart/form-data; boundary="))); - let body = String::from_utf8_lossy(&request.body_bytes); - for expected in [ - "name=\"artifact\"; filename=\"artifact.tar.gz\"", - "Content-Type: application/gzip", - artifact_marker, - "name=\"environment\"\r\n\r\nproduction", - "name=\"version_id\"\r\n\r\nver-123", - "name=\"deployment_id\"\r\n\r\ndpl-123", - ] { - assert!( - body.contains(expected), - "multipart body omitted {expected:?}" - ); - } - assert!(!body.contains("publisher")); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - #[test] fn bb_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { let kgoose = MockServer::start(vec![]); diff --git a/bb-cli/tests/common/mod.rs b/bb-cli/tests/common/mod.rs index 13aabc882..3c99f5861 100644 --- a/bb-cli/tests/common/mod.rs +++ b/bb-cli/tests/common/mod.rs @@ -197,7 +197,6 @@ pub fn bb_command() -> Command { .env_remove("BB_KGOOSE_PLAYPEN") .env_remove("BB_AUTH_STORAGE") .env_remove("BB_AUTH_STORAGE_FILE") - .env_remove("BB_APPS_E2E_CONTROL_PLANE_URL") .env_remove("KGOOSE_BASE_URL") .env_remove("KGOOSE_DEBUG") .env_remove("KGOOSE_PLAYPEN") From 8bc2ef96bb12dbea3dd9532aaba7a8244681d19f Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 21:32:42 -0400 Subject: [PATCH 08/10] Make Apps auth explicitly noninteractive --- bb-cli/Cargo.lock | 127 ++++- bb-cli/Cargo.toml | 4 + bb-cli/Justfile | 2 +- bb-cli/src/bb/apps.rs | 312 ++--------- bb-cli/src/bb/auth_login.rs | 131 +---- bb-cli/tests/bb_e2e.rs | 505 ++++++++++++++++++ bb-cli/tests/common/mod.rs | 1 + bb-cli/tests/fixtures/apps-e2e-ca.pem | 20 + bb-cli/tests/fixtures/apps-e2e-server-key.pem | 28 + bb-cli/tests/fixtures/apps-e2e-server.pem | 22 + 10 files changed, 759 insertions(+), 393 deletions(-) create mode 100644 bb-cli/tests/fixtures/apps-e2e-ca.pem create mode 100644 bb-cli/tests/fixtures/apps-e2e-server-key.pem create mode 100644 bb-cli/tests/fixtures/apps-e2e-server.pem diff --git a/bb-cli/Cargo.lock b/bb-cli/Cargo.lock index 5eb401f81..bafa1bb3d 100644 --- a/bb-cli/Cargo.lock +++ b/bb-cli/Cargo.lock @@ -154,6 +154,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -670,7 +676,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.43", "tokio", "tokio-rustls", "tower-service", @@ -696,7 +702,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1079,7 +1085,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" dependencies = [ - "base64", + "base64 0.22.1", "serde", ] @@ -1330,7 +1336,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.43", "socket2", "thiserror", "tokio", @@ -1349,9 +1355,9 @@ dependencies = [ "lru-slab", "rand", "rand_pcg", - "ring", + "ring 0.17.14", "rustc-hash", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "slab", "thiserror", @@ -1450,7 +1456,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1467,7 +1473,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "serde", "serde_json", @@ -1485,6 +1491,21 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -1495,7 +1516,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1527,6 +1548,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1534,13 +1567,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", - "ring", + "ring 0.17.14", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64 0.13.1", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1557,9 +1599,9 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1583,6 +1625,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework-sys" version = "2.17.0" @@ -1728,6 +1780,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "sq-kgoose" version = "0.7.11" @@ -1860,6 +1918,9 @@ dependencies = [ "chunked_transfer", "httpdate", "log", + "rustls 0.20.9", + "rustls-pemfile", + "zeroize", ] [[package]] @@ -1919,7 +1980,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.43", "tokio", ] @@ -1956,7 +2017,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -2126,6 +2187,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2278,6 +2345,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2287,6 +2364,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2296,6 +2389,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/bb-cli/Cargo.toml b/bb-cli/Cargo.toml index ca433e4c9..3de0262c5 100644 --- a/bb-cli/Cargo.toml +++ b/bb-cli/Cargo.toml @@ -6,6 +6,9 @@ rust-version = "1.88.0" license = "Apache-2.0" publish = false +[features] +apps-e2e-test = [] + [[bin]] name = "agent-tools" path = "src/main.rs" @@ -42,3 +45,4 @@ tonic-prost-build = "0.14" [dev-dependencies] tempfile = "3" +tiny_http = { version = "0.12", features = ["ssl-rustls"] } diff --git a/bb-cli/Justfile b/bb-cli/Justfile index 3043dcc35..7d9e3cd78 100644 --- a/bb-cli/Justfile +++ b/bb-cli/Justfile @@ -104,7 +104,7 @@ lint: fmt-check cargo clippy --locked --all-targets --all-features -- -D warnings test: - cargo test --locked + cargo test --locked --features apps-e2e-test package-smoke: build-sq ./sqbin/{{BIN_NAME}}.exoskeleton --version diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index 46af4be52..9b9fa2238 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -17,24 +17,31 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; -use builderbot_auth::auth_login::{auth_url, build_auth_http_client}; +use builderbot_auth::auth_login::auth_url; +#[cfg(test)] +use builderbot_auth::auth_login::build_auth_http_client; use builderbot_auth::auth_storage::StoredSessionCredential; use clap::{Arg, ArgMatches, Command}; use reqwest::blocking::{multipart, Client, Request, RequestBuilder, Response}; use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; +use reqwest::redirect::Policy; +#[cfg(feature = "apps-e2e-test")] +use reqwest::Certificate; use reqwest::StatusCode; use serde::Serialize; use serde_json::{json, Map, Value}; -use super::auth_login::{ensure_browser_login, verify_stored_session}; +use super::auth_login::verify_stored_session; use super::auth_storage::default_session_storage; -use super::display::{print_json, stdin_is_tty, terminal_safe_text, Style}; +use super::display::{print_json, terminal_safe_text, Style}; use super::runner; use super::skills_api::{exit_codes, failure}; use super::skills_config::SkillsConfig; const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; +#[cfg(feature = "apps-e2e-test")] +const APPS_E2E_RESOLVE_ADDR_ENV_VAR: &str = "BB_APPS_E2E_RESOLVE_ADDR"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; @@ -200,14 +207,6 @@ fn run_contract(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let (client, credential) = control_plane_context(config, matches)?; - print_json(&create_response(&client, &credential, matches)?) -} - -fn create_response( - client: &ControlPlaneClient, - credential: &ComposeSessionCredential, - matches: &ArgMatches, -) -> Result { let request = PlanRequest { app_id: matches.get_one::("app-id").map(String::as_str), name: matches.get_one::("name").map(String::as_str), @@ -218,7 +217,7 @@ fn create_response( persistence: matches.get_one::("persistence").map(String::as_str), client_version: client.client_version_text(), }; - let plan = client.plan(credential, &request)?; + let plan = client.plan(&credential, &request)?; let app_id = required_response_string(&plan, "app_id", "Apps Platform plan")?.to_string(); let initialize_required = plan .pointer("/initialize/required") @@ -235,7 +234,7 @@ fn create_response( initialize_required.unwrap_or(false) || initialize_recommended.unwrap_or(false); let initialize = if should_initialize { let request = initialize_request_from_plan(&plan); - Some(client.initialize(credential, &app_id, &request)?) + Some(client.initialize(&credential, &app_id, &request)?) } else { None }; @@ -252,7 +251,7 @@ fn create_response( plan.get("external_url").cloned().unwrap_or(Value::Null), ), }; - Ok(json!({ + print_json(&json!({ "ok": true, "app_id": effective_app_id, "external_url": effective_external_url, @@ -263,19 +262,6 @@ fn create_response( } fn run_deploy(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let artifact = matches - .get_one::("artifact") - .context("expected artifact.tar.gz path")?; - validate_artifact_path(artifact)?; - let (client, credential) = control_plane_context(config, matches)?; - print_json(&deploy_response(&client, &credential, matches)?) -} - -fn deploy_response( - client: &ControlPlaneClient, - credential: &ComposeSessionCredential, - matches: &ArgMatches, -) -> Result { let artifact = matches .get_one::("artifact") .context("expected artifact.tar.gz path")?; @@ -288,7 +274,9 @@ fn deploy_response( version_id: matches.get_one::("version-id").cloned(), deployment_id: matches.get_one::("deployment-id").cloned(), }; - client.deploy(credential, app_id, artifact, &options) + let (client, credential) = control_plane_context(config, matches)?; + let response = client.deploy(&credential, app_id, artifact, &options)?; + print_json(&response) } fn control_plane_context( @@ -377,13 +365,9 @@ struct ComposeSessionCredential { impl ComposeSessionCredential { fn from_config(config: &SkillsConfig) -> Result { let storage = default_session_storage(config)?; - if config.json || !stdin_is_tty() { - let verified = - verify_stored_session(config, storage.as_ref())?.ok_or_else(auth_required_error)?; - Self::from_stored(verified.credential) - } else { - Self::from_stored(ensure_browser_login(config, storage.as_ref())?) - } + let verified = + verify_stored_session(config, storage.as_ref())?.ok_or_else(auth_required_error)?; + Self::from_stored(verified.credential) } fn from_stored(credential: StoredSessionCredential) -> Result { @@ -443,7 +427,7 @@ impl ControlPlaneClient { request_timeout: Duration, ) -> Result { validate_control_plane_base_url(base_url)?; - let client = build_auth_http_client(request_timeout)?; + let client = build_control_plane_http_client(request_timeout)?; Self::build(base_url, client_version, style, client) } @@ -631,6 +615,36 @@ impl ControlPlaneClient { } } +fn build_control_plane_http_client(timeout: Duration) -> Result { + let builder = Client::builder().redirect(Policy::none()).timeout(timeout); + #[cfg(feature = "apps-e2e-test")] + let builder = { + let mut builder = builder; + if let Some(address) = std::env::var_os(APPS_E2E_RESOLVE_ADDR_ENV_VAR) { + let address = address + .into_string() + .map_err(|_| anyhow::anyhow!("{APPS_E2E_RESOLVE_ADDR_ENV_VAR} must be UTF-8"))? + .parse::() + .with_context(|| format!("parse {APPS_E2E_RESOLVE_ADDR_ENV_VAR}"))?; + if !address.ip().is_loopback() { + anyhow::bail!("{APPS_E2E_RESOLVE_ADDR_ENV_VAR} must be a loopback socket address"); + } + let certificate = + Certificate::from_pem(include_bytes!("../../tests/fixtures/apps-e2e-ca.pem")) + .context("parse Apps E2E test CA")?; + builder = builder + .no_proxy() + .add_root_certificate(certificate) + .resolve(TRUSTED_CONTROL_PLANE_HOSTS[0], address) + .resolve(TRUSTED_CONTROL_PLANE_HOSTS[1], address); + } + builder + }; + builder + .build() + .context("build Apps Platform control-plane HTTP client") +} + fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result { let artifact_part = multipart::Part::file(artifact) .with_context(|| format!("open Apps Platform artifact {}", artifact.display()))? @@ -806,22 +820,6 @@ mod tests { .expect("build test control-plane client") } - fn parsed_apps_subcommand(args: &[&str]) -> ArgMatches { - command() - .try_get_matches_from(args) - .expect("parse Apps command") - .subcommand() - .expect("Apps subcommand") - .1 - .clone() - } - - fn json_response(value: &Value) -> Response>> { - Response::from_string(value.to_string()).with_header( - Header::from_bytes("Content-Type", "application/json").expect("build content type"), - ) - } - fn test_credential(secret: &str) -> ComposeSessionCredential { ComposeSessionCredential::new(secret.to_string()).expect("build test credential") } @@ -864,216 +862,6 @@ mod tests { ); } - #[test] - fn create_command_runs_plan_and_required_initialize() { - let plan = json!({ - "app_id": "merchant-lookup", - "display_name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default", - "initialize": {"required": true, "recommended": false} - }); - let initialized = json!({ - "app_id": "merchant-lookup-2", - "external_url": "https://merchant-lookup-2.example" - }); - let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); - let base_url = format!("http://{}", server.server_addr()); - let server_thread = thread::spawn({ - let plan = plan.clone(); - let initialized = initialized.clone(); - move || { - let mut request = server.recv().expect("receive plan request"); - assert_eq!(request.url(), APPS_PLAN_PATH); - let mut body = String::new(); - request - .as_reader() - .read_to_string(&mut body) - .expect("read plan request"); - assert_eq!( - serde_json::from_str::(&body).expect("parse plan request"), - json!({ - "app_id": "merchant-lookup", - "name": "Merchant Lookup", - "environment": "staging", - "runtime_profile": "fetch-js", - "persistence": "sqlite", - "client_version": "1.0.0" - }) - ); - request - .respond(json_response(&plan)) - .expect("respond to plan"); - - let mut request = server.recv().expect("receive initialize request"); - assert_eq!(request.url(), "/v1/agent/apps/merchant-lookup/initialize"); - let mut body = String::new(); - request - .as_reader() - .read_to_string(&mut body) - .expect("read initialize request"); - assert_eq!( - serde_json::from_str::(&body).expect("parse initialize request"), - json!({ - "name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default" - }) - ); - request - .respond(json_response(&initialized)) - .expect("respond to initialize"); - } - }); - let client = test_control_plane_client(&base_url, Duration::from_secs(2)); - let matches = parsed_apps_subcommand(&[ - "apps", - "create", - "--app-id", - "merchant-lookup", - "--name", - "Merchant Lookup", - "--environment", - "staging", - "--runtime-profile", - "fetch-js", - "--persistence", - "sqlite", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "1.0.0", - ]); - - let response = create_response( - &client, - &test_credential("create_session_credential_123456"), - &matches, - ) - .expect("create app"); - - assert_eq!(response["app_id"], "merchant-lookup-2"); - assert_eq!(response["initialized"], true); - assert_eq!(response["plan"], plan); - assert_eq!(response["initialize"], initialized); - server_thread.join().expect("join control-plane server"); - } - - #[test] - fn create_command_skips_initialize_when_plan_does_not_request_it() { - let plan = json!({ - "app_id": "existing-app", - "external_url": "https://existing-app.example", - "initialize": {"required": false, "recommended": false} - }); - let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); - let base_url = format!("http://{}", server.server_addr()); - let server_thread = thread::spawn({ - let plan = plan.clone(); - move || { - let request = server.recv().expect("receive plan request"); - assert_eq!(request.url(), APPS_PLAN_PATH); - request - .respond(json_response(&plan)) - .expect("respond to plan"); - assert!(server - .recv_timeout(Duration::from_millis(250)) - .expect("wait for unexpected initialize") - .is_none()); - } - }); - let client = test_control_plane_client(&base_url, Duration::from_secs(2)); - let matches = parsed_apps_subcommand(&[ - "apps", - "create", - "--app-id", - "existing-app", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "1.0.0", - ]); - - let response = create_response( - &client, - &test_credential("existing_session_credential_123456"), - &matches, - ) - .expect("reuse existing app"); - - assert_eq!(response["app_id"], "existing-app"); - assert_eq!(response["initialized"], false); - assert!(response["initialize"].is_null()); - server_thread.join().expect("join control-plane server"); - } - - #[test] - fn deploy_command_uploads_the_parsed_artifact_and_options() { - let temporary_directory = tempfile::tempdir().expect("create temporary directory"); - let artifact_path = temporary_directory.path().join("artifact.tar.gz"); - fs::write(&artifact_path, b"test-deploy-artifact").expect("write artifact"); - let deployed = json!({"ok": true, "version_id": "ver-123"}); - let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); - let base_url = format!("http://{}", server.server_addr()); - let server_thread = thread::spawn({ - let deployed = deployed.clone(); - move || { - let mut request = server.recv().expect("receive deploy request"); - assert_eq!(request.url(), "/v1/agent/apps/merchant-lookup/deploy"); - let mut body = Vec::new(); - request - .as_reader() - .read_to_end(&mut body) - .expect("read deploy request"); - let body = String::from_utf8_lossy(&body); - for expected in [ - "test-deploy-artifact", - "name=\"environment\"\r\n\r\nproduction", - "name=\"version_id\"\r\n\r\nver-123", - "name=\"deployment_id\"\r\n\r\ndpl-123", - ] { - assert!( - body.contains(expected), - "multipart body omitted {expected:?}" - ); - } - request - .respond(json_response(&deployed)) - .expect("respond to deploy"); - } - }); - let client = test_control_plane_client(&base_url, Duration::from_secs(2)); - let artifact = artifact_path.to_str().expect("artifact path"); - let matches = parsed_apps_subcommand(&[ - "apps", - "deploy", - "merchant-lookup", - artifact, - "--environment", - "production", - "--version-id", - "ver-123", - "--deployment-id", - "dpl-123", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "1.0.0", - ]); - - let response = deploy_response( - &client, - &test_credential("deploy_session_credential_123456"), - &matches, - ) - .expect("deploy app"); - - assert_eq!(response, deployed); - server_thread.join().expect("join control-plane server"); - } - #[test] fn control_plane_allowlist_is_exact_and_https_only() { let style = Style::new(true, false, false); diff --git a/bb-cli/src/bb/auth_login.rs b/bb-cli/src/bb/auth_login.rs index fce1ec1bd..1e0b25ade 100644 --- a/bb-cli/src/bb/auth_login.rs +++ b/bb-cli/src/bb/auth_login.rs @@ -19,7 +19,6 @@ use super::auth_storage::{session_storage_key_from_config, SessionCredentialStor use super::skills_config::{kgoose_service_url, SkillsConfig}; const CALLBACK_PATH: &str = "/callback"; -const EMBEDDED_LOGIN_CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60); #[derive(Clone, Copy)] enum AuthCallbackPage { @@ -43,7 +42,6 @@ pub struct BrowserLoginSummary { struct BrowserLoginOutcome { summary: BrowserLoginSummary, - credential: StoredSessionCredential, } pub struct VerifiedStoredSession { @@ -62,75 +60,12 @@ pub fn run_browser_login( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, ) -> Result { - run_browser_login_with_output(config, storage, BrowserLoginOutput::Standalone, None) - .map(|outcome| outcome.summary) + run_browser_login_inner(config, storage).map(|outcome| outcome.summary) } -pub fn ensure_browser_login( +fn run_browser_login_inner( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, -) -> Result { - run_browser_login_with_output( - config, - storage, - BrowserLoginOutput::Embedded, - Some(EMBEDDED_LOGIN_CALLBACK_TIMEOUT), - ) - .map(|outcome| outcome.credential) -} - -#[derive(Clone, Copy)] -enum BrowserLoginOutput { - Standalone, - Embedded, -} - -impl BrowserLoginOutput { - fn info(self, config: &SkillsConfig, message: &str) { - match self { - Self::Standalone => auth_info(config, message), - Self::Embedded => config.style.verbose(message), - } - } - - fn login_url(self, config: &SkillsConfig, login_url: &Url) { - if config.json { - return; - } - match self { - Self::Standalone => { - println!("Opening BuilderBot auth login in your browser:"); - println!("{login_url}"); - } - Self::Embedded => { - eprintln!("Opening BuilderBot auth login in your browser:"); - eprintln!("{login_url}"); - } - } - } - - fn browser_fallback(self) { - match self { - Self::Standalone => { - println!("Could not open a browser automatically. Open the URL above manually.") - } - Self::Embedded => { - eprintln!("Could not open a browser automatically. Open the URL above manually.") - } - } - } - - #[cfg(test)] - fn writes_command_stdout(self) -> bool { - matches!(self, Self::Standalone) - } -} - -fn run_browser_login_with_output( - config: &SkillsConfig, - storage: &dyn SessionCredentialStorage, - output: BrowserLoginOutput, - callback_timeout: Option, ) -> Result { let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); let client = build_auth_http_client(Duration::from_secs(30))?; @@ -144,7 +79,7 @@ fn run_browser_login_with_output( &service_url, &stored, )? { - output.info( + auth_info( config, &format!( "Found valid BuilderBot CLI auth session in {} storage", @@ -163,10 +98,9 @@ fn run_browser_login_with_output( credential_prefix: None, credential_sha256_prefix: None, }, - credential: stored, }); } - output.info( + auth_info( config, &format!( "Stored BuilderBot CLI auth session in {} storage is invalid", @@ -175,7 +109,7 @@ fn run_browser_login_with_output( ); } None => { - output.info( + auth_info( config, &format!( "No BuilderBot CLI auth session found in {} storage", @@ -196,22 +130,27 @@ fn run_browser_login_with_output( let _ = tx.send(result); }); - output.login_url(config, &login_url); + if !config.json { + println!("Opening BuilderBot auth login in your browser:"); + println!("{login_url}"); + } if let Err(error) = webbrowser::open(login_url.as_str()) { if config.json { return Err(anyhow!( "failed to open browser for BuilderBot auth login: {error}" )); } - output.browser_fallback(); + println!("Could not open a browser automatically. Open the URL above manually."); } - let code = wait_for_login_callback(rx, callback_timeout)?; + let code = rx + .recv() + .context("loopback auth server stopped before login completed")??; let verified = exchange_login_code_and_verify(&client, config.playpen.as_deref(), &service_url, &code)?; let (stored, me, workspace_name) = validate_and_store_login_credential(storage, &storage_key, verified)?; - output.info( + auth_info( config, &format!( "Stored BuilderBot CLI auth session in {} storage", @@ -230,29 +169,9 @@ fn run_browser_login_with_output( credential_prefix: Some(safe_prefix(&stored.session_credential)), credential_sha256_prefix: Some(sha256_prefix(&stored.session_credential)), }, - credential: stored, }) } -fn wait_for_login_callback( - rx: mpsc::Receiver>, - timeout: Option, -) -> Result { - match timeout { - Some(timeout) => rx.recv_timeout(timeout).map_err(|error| match error { - mpsc::RecvTimeoutError::Timeout => { - anyhow!("BuilderBot auth login timed out; run `bb auth login` to try again") - } - mpsc::RecvTimeoutError::Disconnected => { - anyhow!("loopback auth server stopped before login completed") - } - })?, - None => rx - .recv() - .context("loopback auth server stopped before login completed")?, - } -} - pub fn verify_stored_session( config: &SkillsConfig, storage: &dyn SessionCredentialStorage, @@ -444,8 +363,6 @@ fn auth_info(config: &SkillsConfig, message: &str) { mod tests { use std::cell::{Cell, RefCell}; use std::collections::VecDeque; - use std::sync::mpsc; - use std::time::{Duration, Instant}; use anyhow::Result; use builderbot_auth::auth_login::{ @@ -457,7 +374,7 @@ mod tests { use super::{ auth_callback_page, store_login_credential, validate_and_store_login_credential, - verify_stored_session_with, wait_for_login_callback, AuthCallbackPage, BrowserLoginOutput, + verify_stored_session_with, AuthCallbackPage, }; struct SwappingStorage { @@ -521,24 +438,6 @@ mod tests { } } - #[test] - fn embedded_login_never_writes_command_stdout() { - assert!(!BrowserLoginOutput::Embedded.writes_command_stdout()); - assert!(BrowserLoginOutput::Standalone.writes_command_stdout()); - } - - #[test] - fn embedded_login_callback_wait_is_bounded() { - let (_tx, rx) = mpsc::channel(); - let started = Instant::now(); - - let error = wait_for_login_callback(rx, Some(Duration::from_millis(10))) - .expect_err("time out abandoned embedded login"); - - assert!(error.to_string().contains("auth login timed out")); - assert!(started.elapsed() < Duration::from_secs(1)); - } - #[test] fn verification_returns_the_exact_credential_that_was_checked() { let storage = SwappingStorage::new([stored("verified-a"), stored("substituted-b")]); diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index dc77068bb..0be41015d 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -8,6 +8,8 @@ use std::fs; use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; +#[cfg(feature = "apps-e2e-test")] +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -3616,6 +3618,191 @@ fn bb_skills_doctor_offline_reports_server_failure() { // --------------------------------------------------------------------------- // External Apps Platform control plane +const APPROVED_APPS_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + +#[cfg(feature = "apps-e2e-test")] +struct AppsHttpsMockServer { + address: String, + requests: Arc>>, + handle: Option>, +} + +#[cfg(feature = "apps-e2e-test")] +impl AppsHttpsMockServer { + fn start(responses: Vec) -> Self { + let server = tiny_http::Server::https( + "127.0.0.1:0", + tiny_http::SslConfig { + certificate: include_bytes!("fixtures/apps-e2e-server.pem").to_vec(), + private_key: include_bytes!("fixtures/apps-e2e-server-key.pem").to_vec(), + }, + ) + .expect("bind Apps HTTPS mock server"); + let address = server + .server_addr() + .to_ip() + .expect("Apps HTTPS mock IP address") + .to_string(); + let requests = Arc::new(Mutex::new(Vec::new())); + let thread_requests = Arc::clone(&requests); + let handle = thread::spawn(move || { + for response in responses { + let mut request = server + .recv_timeout(Duration::from_secs(10)) + .expect("receive Apps HTTPS mock request") + .expect("Apps HTTPS mock request before timeout"); + let method = request.method().as_str().to_string(); + let path = request.url().to_string(); + let headers = request + .headers() + .iter() + .map(|header| { + ( + header.field.as_str().to_string().to_ascii_lowercase(), + header.value.as_str().to_string(), + ) + }) + .collect::>(); + let mut body_bytes = Vec::new(); + request + .as_reader() + .read_to_end(&mut body_bytes) + .expect("read Apps HTTPS mock request"); + let body = if headers + .get("content-type") + .is_some_and(|value| value.starts_with("application/json")) + { + serde_json::from_slice(&body_bytes).expect("parse Apps HTTPS JSON request") + } else { + Value::Null + }; + thread_requests + .lock() + .expect("lock Apps HTTPS requests") + .push(common::RecordedRequest { + method, + path, + headers, + body, + body_bytes, + }); + + let mut reply = + tiny_http::Response::from_data(response.body).with_status_code(response.status); + for (name, value) in response.headers { + reply.add_header( + tiny_http::Header::from_bytes(name, value) + .expect("build Apps HTTPS response header"), + ); + } + request + .respond(reply) + .expect("respond to Apps HTTPS request"); + } + }); + Self { + address, + requests, + handle: Some(handle), + } + } + + fn finish(mut self) -> Vec { + self.handle + .take() + .expect("Apps HTTPS mock server handle") + .join() + .expect("join Apps HTTPS mock server"); + self.requests + .lock() + .expect("lock Apps HTTPS requests") + .clone() + } +} + +#[cfg(feature = "apps-e2e-test")] +fn apps_auth_me_response() -> MockResponse { + MockResponse::json(json!({ + "subject": "auth0|apps-user", + "email": "apps@example.com", + "name": "Apps User", + "expires_at": "2099-01-01T00:00:00Z", + "workspaces": {"active": [{"name": "Test Workspace"}]} + })) +} + +#[cfg(feature = "apps-e2e-test")] +fn configured_apps_command( + auth_server: &MockServer, + control_plane: &AppsHttpsMockServer, + temp: &Path, + credential: &str, +) -> std::process::Command { + let bb_home = temp.join("bb-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bb_org_config(&bb_home, "test"); + write_browser_auth_session( + &storage_path, + &auth_server.base_url, + credential, + "2099-01-01T00:00:00Z", + ); + let mut command = bb_command(); + command + .env("BB_HOME", bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", storage_path) + .env("KGOOSE_BASE_URL", &auth_server.base_url) + .env("BB_APPS_E2E_RESOLVE_ADDR", &control_plane.address); + command +} + +#[cfg(feature = "apps-e2e-test")] +fn assert_apps_auth_request(request: &common::RecordedRequest, credential: &str) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/api/goose/v1/auth/me"); + assert_eq!( + request + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(credential) + ); + assert!(!request.headers.contains_key("authorization")); +} + +#[cfg(feature = "apps-e2e-test")] +fn assert_apps_control_plane_request( + request: &common::RecordedRequest, + method: &str, + path: &str, + credential: &str, + client_version: &str, +) { + let expected_authorization = format!("BBIdentity {credential}"); + assert_eq!(request.method, method); + assert_eq!(request.path, path); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(expected_authorization.as_str()) + ); + assert_eq!( + request + .headers + .get("x-hotpod-agent-client-version") + .map(String::as_str), + Some(client_version) + ); + for forbidden in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-user", + "x-forwarded-workspace-id", + ] { + assert!(!request.headers.contains_key(forbidden)); + } +} + #[test] fn bb_apps_help_distinguishes_external_and_internal_paths() { let output = bb_command() @@ -3640,6 +3827,272 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } } +#[cfg(feature = "apps-e2e-test")] +#[test] +fn bb_apps_contract_verifies_and_uses_the_same_session() { + let credential = "contract.session+credential"; + let contract = json!({ + "ok": true, + "contract_version": "2026-06-30", + "supported_operations": [{"method": "GET", "path": "/v1/agent/contract"}] + }); + let auth_server = MockServer::start(vec![apps_auth_me_response()]); + let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(contract.clone())]); + let temp = temp_test_dir("bb-apps-contract-success"); + + let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) + .args([ + "apps", + "contract", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps contract"); + let auth_requests = auth_server.finish(); + let control_plane_requests = control_plane.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse contract output"), + contract + ); + assert!(!stdout.contains(credential)); + assert!(!stderr.contains(credential)); + assert_eq!(auth_requests.len(), 1); + assert_apps_auth_request(&auth_requests[0], credential); + assert_eq!(control_plane_requests.len(), 1); + assert_apps_control_plane_request( + &control_plane_requests[0], + "GET", + "/v1/agent/contract", + credential, + "0.2.0", + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[cfg(feature = "apps-e2e-test")] +#[test] +fn bb_apps_create_runs_plan_and_initialize_end_to_end() { + let credential = "create.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2--bpsites.example/" + }); + let auth_server = MockServer::start(vec![apps_auth_me_response()]); + let control_plane = AppsHttpsMockServer::start(vec![ + MockResponse::json(plan.clone()), + MockResponse::json(initialized.clone()), + ]); + let temp = temp_test_dir("bb-apps-create-success"); + + let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) + .args([ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + "fetch-js", + "--persistence", + "sqlite", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps create"); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse create output"); + assert_eq!(response["app_id"], json!("merchant-lookup-2")); + assert_eq!(response["initialized"], json!(true)); + assert_eq!(response["plan"], plan); + assert_eq!(response["initialize"], initialized); + assert_apps_auth_request(&auth_requests[0], credential); + assert_eq!(requests.len(), 2); + assert_apps_control_plane_request( + &requests[0], + "POST", + "/v1/agent/apps/plan", + credential, + "0.2.0", + ); + assert_eq!( + requests[0].body, + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": "fetch-js", + "persistence": "sqlite", + "client_version": "0.2.0" + }) + ); + assert_apps_control_plane_request( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + "0.2.0", + ); + assert_eq!( + requests[1].body, + json!({ + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default" + }) + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[cfg(feature = "apps-e2e-test")] +#[test] +fn bb_apps_create_skips_initialize_when_plan_does_not_request_it() { + let credential = "existing.session+credential"; + let plan = json!({ + "app_id": "existing-app", + "external_url": "https://existing-app--bpsites.example/", + "initialize": {"required": false, "recommended": false} + }); + let auth_server = MockServer::start(vec![apps_auth_me_response()]); + let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(plan.clone())]); + let temp = temp_test_dir("bb-apps-create-existing-success"); + + let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) + .args([ + "apps", + "create", + "--app-id", + "existing-app", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps create without initialize"); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse create output"); + assert_eq!(response["app_id"], json!("existing-app")); + assert_eq!(response["initialized"], json!(false)); + assert_eq!(response["initialize"], Value::Null); + assert_eq!(response["plan"], plan); + assert_apps_auth_request(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_apps_control_plane_request( + &requests[0], + "POST", + "/v1/agent/apps/plan", + credential, + "0.2.0", + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[cfg(feature = "apps-e2e-test")] +#[test] +fn bb_apps_deploy_uploads_multipart_artifact_end_to_end() { + let credential = "deploy.session+credential"; + let deployed = json!({ + "ok": true, + "app_id": "merchant-lookup", + "version_id": "ver-123", + "deployment_id": "dpl-123" + }); + let auth_server = MockServer::start(vec![apps_auth_me_response()]); + let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(deployed.clone())]); + let temp = temp_test_dir("bb-apps-deploy-success"); + let artifact_path = temp.join("prepared-app.tar.gz"); + fs::write(&artifact_path, "test-hotpod-artifact-marker").expect("write deploy artifact"); + + let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) + .args([ + "apps", + "deploy", + "merchant-lookup", + artifact_path.to_str().expect("artifact path text"), + "--environment", + "production", + "--version-id", + "ver-123", + "--deployment-id", + "dpl-123", + "--base-url", + APPROVED_APPS_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]) + .output() + .expect("run bb apps deploy"); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse deploy output"), + deployed + ); + assert_apps_auth_request(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_apps_control_plane_request( + request, + "POST", + "/v1/agent/apps/merchant-lookup/deploy", + credential, + "0.2.0", + ); + assert!(request + .headers + .get("content-type") + .is_some_and(|value| value.starts_with("multipart/form-data; boundary="))); + let body = String::from_utf8_lossy(&request.body_bytes); + for expected in [ + "test-hotpod-artifact-marker", + "name=\"environment\"\r\n\r\nproduction", + "name=\"version_id\"\r\n\r\nver-123", + "name=\"deployment_id\"\r\n\r\ndpl-123", + ] { + assert!( + body.contains(expected), + "multipart body omitted {expected:?}" + ); + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + #[test] fn bb_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { let kgoose = MockServer::start(vec![]); @@ -3754,6 +4207,58 @@ fn bb_apps_json_without_a_session_exits_promptly_with_auth_required() { fs::remove_dir_all(temp).expect("remove temp dir"); } +#[test] +fn bb_apps_pipeline_without_a_session_never_starts_browser_login() { + let temp = temp_test_dir("bb-apps-pipeline-auth-required"); + let bb_home = temp.join("bb-home"); + let storage_path = temp.join("missing-auth-sessions.json"); + write_bb_org_config(&bb_home, "test"); + + let mut child = bb_command() + .env("BB_HOME", &bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", &storage_path) + .args(["apps", "contract", "--base-url", APPROVED_APPS_BASE_URL]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start piped bb apps contract"); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().expect("poll bb apps contract") { + break status; + } + if Instant::now() >= deadline { + child.kill().expect("stop hung bb apps contract"); + child.wait().expect("reap hung bb apps contract"); + panic!("piped bb apps contract did not fail promptly without a session"); + } + thread::sleep(Duration::from_millis(10)); + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + child + .stdout + .take() + .expect("capture stdout") + .read_to_string(&mut stdout) + .expect("read stdout"); + child + .stderr + .take() + .expect("capture stderr") + .read_to_string(&mut stderr) + .expect("read stderr"); + + assert_eq!(status.code(), Some(3), "stderr was: {stderr}"); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("BuilderBot CLI auth is required")); + assert!(!stderr.contains("Opening BuilderBot auth login")); + assert!(!stderr.contains("127.0.0.1")); + assert!(!storage_path.exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + // --------------------------------------------------------------------------- // bb tools passthrough diff --git a/bb-cli/tests/common/mod.rs b/bb-cli/tests/common/mod.rs index 3c99f5861..f31ccac86 100644 --- a/bb-cli/tests/common/mod.rs +++ b/bb-cli/tests/common/mod.rs @@ -197,6 +197,7 @@ pub fn bb_command() -> Command { .env_remove("BB_KGOOSE_PLAYPEN") .env_remove("BB_AUTH_STORAGE") .env_remove("BB_AUTH_STORAGE_FILE") + .env_remove("BB_APPS_E2E_RESOLVE_ADDR") .env_remove("KGOOSE_BASE_URL") .env_remove("KGOOSE_DEBUG") .env_remove("KGOOSE_PLAYPEN") diff --git a/bb-cli/tests/fixtures/apps-e2e-ca.pem b/bb-cli/tests/fixtures/apps-e2e-ca.pem new file mode 100644 index 000000000..c3d3a53b0 --- /dev/null +++ b/bb-cli/tests/fixtures/apps-e2e-ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMTCCAhmgAwIBAgIUXXVunM6gfBEjusyIqFCiDcKCxVQwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBUZXN0IENBMB4XDTI2MDgxODAw +NTc1NFoXDTM2MDgxNTAwNTc1NFowIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBU +ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1yQz5c4CDpNh +MhdqIiuEBYmOkv7JzB0ihKqAUV7f+yjNnwT1kLpO5Chv1CARabUuJTV+KJ1lh+hV +drUQJpIUp+y90zmG5yi6Y5I/bulV3V4moHLsP31qLkcEsVF93RnbKNCeUMwbNj/A +tDc0hbmLpLYyr3qbI8UkHviAF0VR5vsnounbK+tpzrcM0z5YnZtfjrM09FvfS4bD +/aHI7BGoVfh4l1Ml3GkJ2b7CTtyLxFvT2NcY03oZrbr7ozp6OABugliEMABcEz3R +dOSrnA3Bj9gQUelFu0z6WJSv389n3BJbwTvwbYbsgxQixpf7C43WC/kAMxTXATX3 +8VlClDqj/QIDAQABo2MwYTAdBgNVHQ4EFgQUix4g3fG+mcVvxxqghx42++EGPREw +HwYDVR0jBBgwFoAUix4g3fG+mcVvxxqghx42++EGPREwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAIi1DnSKgYhJeujJ +FLL4iKobdxdTg1LErlmSh0tiIdJJwruYo00ZH4HlYQJeosOW7mclUFjQdKpmOQ6U +RXwrJ8jkvjaEt3lRA3HYwqYEdUZJmqe++5OdDlh88s6sQgm0VNZtchzlHZqGLqAI +/ganbg/To+ENC+0Cqz7nwr5YOFc96tzsOVaN0l4HxxxelsFhi3eJLFVAeR2cL6gm +SVyyqZEFYhG5DnL3/7TCxB5RJ2Vam3E/ns9NaBN6NtxMfYaaYjaG28ZHi7FegAtv +SGTTzyrGCTD/tpa+ZL4nwe6fMcd/CItb/RbyQTfa+vn3LkV7G3rk3iKS32YrGcQr +NpNdUKQ= +-----END CERTIFICATE----- diff --git a/bb-cli/tests/fixtures/apps-e2e-server-key.pem b/bb-cli/tests/fixtures/apps-e2e-server-key.pem new file mode 100644 index 000000000..134382f2f --- /dev/null +++ b/bb-cli/tests/fixtures/apps-e2e-server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDXBvDGcqOwpZ6/ +z+eHwy8EPfShppoAcytP7oqXRrDkiuwT+beEEGNFWpDeLUY+qpy+DHVJ7xbmaRWU +YwMMSMHWCzOX0sYaS9EMHZrpA2hOUoiVdwpKvIG8gi+7dZsqaFViHRvvoJyS352l +ih5s4K68OaS4TVdd/Zyt1bUsLAWbEd/iwMR9km4YdidMw+0akPEkYzPtsO6ukCwO +ZgmiRiJuR7hVLCQPLTrl2CjD6qIYI5y+eDG0QT2euz2IKfxxdgdpahMX2bKyt8oj +q53td2p79dHQ1YwFNdgjAtTKMSSO06NByd0sUONWVzj/pHRgSIxmEGpEw3Zg6ZX9 +TryDoS2nAgMBAAECggEAZaA0H7aCwrQkCUe7h6CqEfkuK1BQLLJB4C8/dSvF4t39 +oZs+Lr6IDHk3SqpfLrL4DaJZtK25RwCXYGBDSoUAh6cXpUPKuRboIC/FzSb9HzdG +sk1modfiATQOVyzIPwy8ffiAAYsJNSlWmqxioNa3/uHHhguXpSZ97HK6g7vykkzM +kvUTCQEuvmeauC2gUqQAUZjXb7gC5NYf+G5nnn90TSKWNgLD+KP5YrozTqy1GKEB +MvpQeyj1w8dsZXqe7cWeSTgov4nsnMfOd1M2O3hSet7rmgon/UdW09VMUoFs8n9A +/mhgXA29SLwqtztZAmqfqOx7sjDIOz00nTq1ST/UgQKBgQDunaKErhr8sb2dNaVy +/DL+jN82k6VbmtPQSotwP+sMheF9r3zjo4IgO8rUsdKHZWkRut8tQ/XJES+R1UlK +O8Kpht0sVR9greDPsTwIN07Ht8c9+BQnUH/lQIdhft4abJFV4vt6L/TuNfGTFzuR +/UfYS2uPvFvgfL6srh4wbBmXoQKBgQDmsVwFm4+DQc53JhNqENe/zGsi8+1vbnGn +GqqUQKAw3QT0msy9xb2iUfNcB+lb/0L/4Zl8Eg/XKiyKBQo3UpLzcQCP4aqZX4DY +xBz888coqnuWPFFNonvUFGKe/BCtGomu1X8rKRJwBeupSPHAK+2HMiHEA270U7M8 +RQ/2XRQgRwKBgQDoexUYiDkq8lF3lgj4mtdkQwRHPFrjgVnVmot4dg4gSWCFADGB +6JCjrx3TVN11pUxVReijRY92sxPR1iht9wOWABwFUXocy8w5DskaiChtVZT9v3KD +S18QkWpVhzIGNLj1IQ064vaUEGKpmP0lI8yX5AOMK0yoz2FHBO3M58WXgQKBgD7E +82zzLtFgDnWM/qtVed7OGDiidnBjdLkrIE7GZs/k03xawmrAayDHe5gG7xABHJHT +KJgBsh2xc/z58hWreiCTFrwPgwPIYJ6afei1y/LcsFPohZbCJz9FbLAllcQD/IJ9 +xORRgJrKgZzGJEFNsouesGFNLdt9Cr/Tasx19wvxAoGBAMkuSpmbkZpuRbUJ4OIu +Sw4H+tXPal98Y7qypzSlx+R8BZEwb6VQpLwpNlvXA2jeITGwIxf4rh6y5YNwSLqR +eAqC9GKe4AmedjffgaieFBygaHjoRkcCxRd1I0QCD9LE8n8ZpNcKWQhUFZOfC5hR +D/ig96SiIfXYrLBApUxAGLH0 +-----END PRIVATE KEY----- diff --git a/bb-cli/tests/fixtures/apps-e2e-server.pem b/bb-cli/tests/fixtures/apps-e2e-server.pem new file mode 100644 index 000000000..9888c7c8f --- /dev/null +++ b/bb-cli/tests/fixtures/apps-e2e-server.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDpjCCAo6gAwIBAgIUGz3Kcf44bBi5n28jky6JgFJUPW4wDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBUZXN0IENBMB4XDTI2MDgxODAw +NTc1NFoXDTM2MDgxNTAwNTc1NFowLzEtMCsGA1UEAwwkY29tcG9zZS1jdHJsLnRl +c3QuYmxvY2tzdGFnaW5nLmJ1aWxkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA1wbwxnKjsKWev8/nh8MvBD30oaaaAHMrT+6Kl0aw5IrsE/m3hBBjRVqQ +3i1GPqqcvgx1Se8W5mkVlGMDDEjB1gszl9LGGkvRDB2a6QNoTlKIlXcKSryBvIIv +u3WbKmhVYh0b76Cckt+dpYoebOCuvDmkuE1XXf2crdW1LCwFmxHf4sDEfZJuGHYn +TMPtGpDxJGMz7bDurpAsDmYJokYibke4VSwkDy065dgow+qiGCOcvngxtEE9nrs9 +iCn8cXYHaWoTF9mysrfKI6ud7Xdqe/XR0NWMBTXYIwLUyjEkjtOjQcndLFDjVlc4 +/6R0YEiMZhBqRMN2YOmV/U68g6EtpwIDAQABo4HIMIHFMAwGA1UdEwEB/wQCMAAw +DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMFAGA1UdEQRJMEeC +JGNvbXBvc2UtY3RybC50ZXN0LmJsb2Nrc3RhZ2luZy5idWlsZIIfY29tcG9zZS1j +dHJsLmFwcC5idWlsZGVybGFiLnh5ejAdBgNVHQ4EFgQUb2y+CEXraCGai13C5lDm +ChdqHP0wHwYDVR0jBBgwFoAUix4g3fG+mcVvxxqghx42++EGPREwDQYJKoZIhvcN +AQELBQADggEBAI3aldblEEGOWTG3AT4o9i7slMB3MR3+/s5ZMZTtRGNWkVNGnAsL +HSvMyr7xTyRgBvaK2vjjSOU8fMftFin4i9qFvsSc6UoGlH+dIrjfj2kRw16kQLJs +HnOoV1uxrY5X4+edyjqcJJ4hLRtZmYcjvunOHsL2SolWAqKtqUZB6diYNeHrwILD +amSuyLgTSJ5PzS+7QNyOIEMGpvQH020JvYLCTthKv6yy/77UtCuaRpx7vauu3sSj +QMg7ZJwoqfikw6pvUI5cSvzbuv6bh0JwVus21rKepamtT/jfBLu+w6AUWNwJ4Zhu +1+vq+YGcSYNSGu7tow7yZvG0KnR7OLWptg4= +-----END CERTIFICATE----- From 7bf10a3de6c4d3fd2fa7d87e9c448b211179dbec Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 22:08:35 -0400 Subject: [PATCH 09/10] Keep Apps test transport out of shipped bb --- bb-cli/Cargo.lock | 127 +--- bb-cli/Cargo.toml | 4 - bb-cli/Justfile | 2 +- bb-cli/src/bb/apps.rs | 594 ++++++++++++++++-- bb-cli/src/lib.rs | 4 + bb-cli/tests/bb_e2e.rs | 459 +------------- bb-cli/tests/common/mod.rs | 1 - bb-cli/tests/fixtures/apps-e2e-ca.pem | 20 - bb-cli/tests/fixtures/apps-e2e-server-key.pem | 28 - bb-cli/tests/fixtures/apps-e2e-server.pem | 22 - 10 files changed, 572 insertions(+), 689 deletions(-) delete mode 100644 bb-cli/tests/fixtures/apps-e2e-ca.pem delete mode 100644 bb-cli/tests/fixtures/apps-e2e-server-key.pem delete mode 100644 bb-cli/tests/fixtures/apps-e2e-server.pem diff --git a/bb-cli/Cargo.lock b/bb-cli/Cargo.lock index bafa1bb3d..5eb401f81 100644 --- a/bb-cli/Cargo.lock +++ b/bb-cli/Cargo.lock @@ -154,12 +154,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -676,7 +670,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.43", + "rustls", "tokio", "tokio-rustls", "tower-service", @@ -702,7 +696,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -1085,7 +1079,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" dependencies = [ - "base64 0.22.1", + "base64", "serde", ] @@ -1336,7 +1330,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.43", + "rustls", "socket2", "thiserror", "tokio", @@ -1355,9 +1349,9 @@ dependencies = [ "lru-slab", "rand", "rand_pcg", - "ring 0.17.14", + "ring", "rustc-hash", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "slab", "thiserror", @@ -1456,7 +1450,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -1473,7 +1467,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1491,21 +1485,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.14" @@ -1516,7 +1495,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -1548,18 +1527,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.23.43" @@ -1567,22 +1534,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", - "ring 0.17.14", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" -dependencies = [ - "base64 0.13.1", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1599,9 +1557,9 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ - "ring 0.17.14", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -1625,16 +1583,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "security-framework-sys" version = "2.17.0" @@ -1780,12 +1728,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "sq-kgoose" version = "0.7.11" @@ -1918,9 +1860,6 @@ dependencies = [ "chunked_transfer", "httpdate", "log", - "rustls 0.20.9", - "rustls-pemfile", - "zeroize", ] [[package]] @@ -1980,7 +1919,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.43", + "rustls", "tokio", ] @@ -2017,7 +1956,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64", "bytes", "h2", "http", @@ -2187,12 +2126,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -2345,16 +2278,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -2364,22 +2287,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -2389,12 +2296,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/bb-cli/Cargo.toml b/bb-cli/Cargo.toml index 3de0262c5..ca433e4c9 100644 --- a/bb-cli/Cargo.toml +++ b/bb-cli/Cargo.toml @@ -6,9 +6,6 @@ rust-version = "1.88.0" license = "Apache-2.0" publish = false -[features] -apps-e2e-test = [] - [[bin]] name = "agent-tools" path = "src/main.rs" @@ -45,4 +42,3 @@ tonic-prost-build = "0.14" [dev-dependencies] tempfile = "3" -tiny_http = { version = "0.12", features = ["ssl-rustls"] } diff --git a/bb-cli/Justfile b/bb-cli/Justfile index 7d9e3cd78..44053d367 100644 --- a/bb-cli/Justfile +++ b/bb-cli/Justfile @@ -104,7 +104,7 @@ lint: fmt-check cargo clippy --locked --all-targets --all-features -- -D warnings test: - cargo test --locked --features apps-e2e-test + cargo test --locked --all-features package-smoke: build-sq ./sqbin/{{BIN_NAME}}.exoskeleton --version diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index 9b9fa2238..d63fa35bd 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -25,8 +25,6 @@ use clap::{Arg, ArgMatches, Command}; use reqwest::blocking::{multipart, Client, Request, RequestBuilder, Response}; use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; use reqwest::redirect::Policy; -#[cfg(feature = "apps-e2e-test")] -use reqwest::Certificate; use reqwest::StatusCode; use serde::Serialize; use serde_json::{json, Map, Value}; @@ -40,8 +38,8 @@ use super::skills_config::SkillsConfig; const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; -#[cfg(feature = "apps-e2e-test")] -const APPS_E2E_RESOLVE_ADDR_ENV_VAR: &str = "BB_APPS_E2E_RESOLVE_ADDR"; +#[cfg(test)] +const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BB_APPS_E2E_CONTROL_PLANE_URL"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; @@ -410,6 +408,39 @@ trait ControlPlaneTransport { fn execute(&self, request: Request) -> reqwest::Result; } +#[cfg(test)] +struct LoopbackTestTransport { + client: Client, + base_url: url::Url, +} + +#[cfg(test)] +impl LoopbackTestTransport { + fn new(base_url: &str, timeout: Duration) -> Result { + Ok(Self { + client: build_auth_http_client(timeout)?, + base_url: url::Url::parse(base_url).context("parse Apps E2E loopback URL")?, + }) + } +} + +#[cfg(test)] +impl ControlPlaneTransport for LoopbackTestTransport { + fn execute(&self, mut request: Request) -> reqwest::Result { + assert!( + is_trusted_control_plane_url(request.url()), + "request must retain its approved production URL until transport execution: {}", + request.url() + ); + let query = request.url().query().map(str::to_string); + let mut loopback_url = self.base_url.clone(); + loopback_url.set_path(request.url().path()); + loopback_url.set_query(query.as_deref()); + *request.url_mut() = loopback_url; + self.client.execute(request) + } +} + impl ControlPlaneClient { fn new(base_url: &str, client_version: &str, style: Style) -> Result { Self::new_with_timeout( @@ -428,7 +459,22 @@ impl ControlPlaneClient { ) -> Result { validate_control_plane_base_url(base_url)?; let client = build_control_plane_http_client(request_timeout)?; - Self::build(base_url, client_version, style, client) + let control_plane = Self::build(base_url, client_version, style, client)?; + #[cfg(test)] + let control_plane = { + let mut control_plane = control_plane; + if let Some(loopback_url) = std::env::var_os(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR) { + let loopback_url = loopback_url.into_string().map_err(|_| { + anyhow::anyhow!("{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be UTF-8") + })?; + control_plane.test_transport = Some(Box::new(LoopbackTestTransport::new( + &loopback_url, + request_timeout, + )?)); + } + control_plane + }; + Ok(control_plane) } fn build(base_url: &str, client_version: &str, style: Style, client: Client) -> Result { @@ -574,8 +620,10 @@ impl ControlPlaneClient { method, path, status, &body, credential, )); } - serde_json::from_str(&body) - .with_context(|| format!("parse Apps Platform {method} {path} response")) + let mut value = serde_json::from_str(&body) + .with_context(|| format!("parse Apps Platform {method} {path} response"))?; + redact_json_value(&mut value, credential); + Ok(value) } fn request_response( @@ -616,35 +664,33 @@ impl ControlPlaneClient { } fn build_control_plane_http_client(timeout: Duration) -> Result { - let builder = Client::builder().redirect(Policy::none()).timeout(timeout); - #[cfg(feature = "apps-e2e-test")] - let builder = { - let mut builder = builder; - if let Some(address) = std::env::var_os(APPS_E2E_RESOLVE_ADDR_ENV_VAR) { - let address = address - .into_string() - .map_err(|_| anyhow::anyhow!("{APPS_E2E_RESOLVE_ADDR_ENV_VAR} must be UTF-8"))? - .parse::() - .with_context(|| format!("parse {APPS_E2E_RESOLVE_ADDR_ENV_VAR}"))?; - if !address.ip().is_loopback() { - anyhow::bail!("{APPS_E2E_RESOLVE_ADDR_ENV_VAR} must be a loopback socket address"); - } - let certificate = - Certificate::from_pem(include_bytes!("../../tests/fixtures/apps-e2e-ca.pem")) - .context("parse Apps E2E test CA")?; - builder = builder - .no_proxy() - .add_root_certificate(certificate) - .resolve(TRUSTED_CONTROL_PLANE_HOSTS[0], address) - .resolve(TRUSTED_CONTROL_PLANE_HOSTS[1], address); - } - builder - }; - builder + Client::builder() + .redirect(Policy::none()) + .timeout(timeout) .build() .context("build Apps Platform control-plane HTTP client") } +fn redact_json_value(value: &mut Value, credential: &ComposeSessionCredential) { + match value { + Value::String(text) => *text = credential.redact(text), + Value::Array(items) => { + for item in items { + redact_json_value(item, credential); + } + } + Value::Object(object) => { + let mut sanitized = Map::new(); + for (key, mut value) in std::mem::take(object) { + redact_json_value(&mut value, credential); + sanitized.insert(credential.redact(&key), value); + } + *object = sanitized; + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result { let artifact_part = multipart::Part::file(artifact) .with_context(|| format!("open Apps Platform artifact {}", artifact.display()))? @@ -771,41 +817,480 @@ fn control_plane_http_failure( #[cfg(test)] mod tests { + use std::collections::{BTreeMap, VecDeque}; + use std::process::Command as ProcessCommand; + use std::sync::{Arc, Mutex}; use std::thread; + use sha2::{Digest, Sha256}; use tiny_http::{Header, Response, Server}; use super::*; const APPROVED_TEST_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + const PROCESS_STDOUT_BEGIN: &str = "BB_APPS_E2E_STDOUT_BEGIN"; + const PROCESS_STDOUT_END: &str = "BB_APPS_E2E_STDOUT_END"; - struct LoopbackTestTransport { - client: Client, - base_url: url::Url, + #[derive(Clone)] + struct ProcessResponse { + status: u16, + body: Value, } - impl LoopbackTestTransport { - fn new(base_url: &str, timeout: Duration) -> Self { + impl ProcessResponse { + fn json(body: Value) -> Self { + Self { status: 200, body } + } + } + + #[derive(Clone)] + struct ProcessRequest { + method: String, + path: String, + headers: BTreeMap, + body: Value, + body_bytes: Vec, + } + + struct ProcessServer { + base_url: String, + requests: Arc>>, + handle: Option>, + } + + impl ProcessServer { + fn start(responses: Vec) -> Self { + let server = Server::http("127.0.0.1:0").expect("bind Apps process test server"); + let base_url = format!("http://{}", server.server_addr()); + let requests = Arc::new(Mutex::new(Vec::new())); + let thread_requests = Arc::clone(&requests); + let handle = thread::spawn(move || { + let mut responses = VecDeque::from(responses); + while let Some(response) = responses.pop_front() { + let mut request = server + .recv_timeout(Duration::from_secs(10)) + .expect("receive Apps process request") + .expect("Apps process request before timeout"); + let headers = request + .headers() + .iter() + .map(|header| { + ( + header.field.as_str().to_string().to_ascii_lowercase(), + header.value.as_str().to_string(), + ) + }) + .collect::>(); + let mut body_bytes = Vec::new(); + request + .as_reader() + .read_to_end(&mut body_bytes) + .expect("read Apps process request"); + let body = if headers + .get("content-type") + .is_some_and(|value| value.starts_with("application/json")) + { + serde_json::from_slice(&body_bytes) + .expect("parse Apps process JSON request") + } else { + Value::Null + }; + thread_requests + .lock() + .expect("lock Apps process requests") + .push(ProcessRequest { + method: request.method().as_str().to_string(), + path: request.url().to_string(), + headers, + body, + body_bytes, + }); + request + .respond( + Response::from_string(response.body.to_string()) + .with_status_code(response.status) + .with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build Apps process content type"), + ), + ) + .expect("respond to Apps process request"); + } + }); Self { - client: build_auth_http_client(timeout).expect("build test transport client"), - base_url: url::Url::parse(base_url).expect("parse test transport URL"), + base_url, + requests, + handle: Some(handle), } } + + fn finish(mut self) -> Vec { + self.handle + .take() + .expect("Apps process server handle") + .join() + .expect("join Apps process server"); + self.requests + .lock() + .expect("lock Apps process requests") + .clone() + } } - impl ControlPlaneTransport for LoopbackTestTransport { - fn execute(&self, mut request: Request) -> reqwest::Result { - assert!( - is_trusted_control_plane_url(request.url()), - "request must retain its approved production URL until transport execution: {}", - request.url() - ); - let query = request.url().query().map(str::to_string); - let mut loopback_url = self.base_url.clone(); - loopback_url.set_path(request.url().path()); - loopback_url.set_query(query.as_deref()); - *request.url_mut() = loopback_url; - self.client.execute(request) + #[test] + fn bb_apps_e2e_process_helper() { + let Some(args) = std::env::var_os("BB_APPS_E2E_ARGS") else { + return; + }; + let args = serde_json::from_str::>( + args.to_str().expect("BB_APPS_E2E_ARGS must be UTF-8"), + ) + .expect("parse BB_APPS_E2E_ARGS"); + println!("{PROCESS_STDOUT_BEGIN}"); + crate::run_bb_with_argv(args).expect("run bb Apps process command"); + println!("{PROCESS_STDOUT_END}"); + } + + fn process_auth_response() -> ProcessResponse { + ProcessResponse::json(json!({ + "subject": "auth0|apps-user", + "email": "apps@example.com", + "name": "Apps User", + "expires_at": "2099-01-01T00:00:00Z", + "workspaces": {"active": [{"name": "Test Workspace"}]} + })) + } + + fn process_command( + auth_server: &ProcessServer, + control_plane: &ProcessServer, + args: &[&str], + credential: &str, + ) -> (tempfile::TempDir, ProcessCommand) { + let temp = tempfile::tempdir().expect("create Apps process temp directory"); + let bb_home = temp.path().join("bb-home"); + let storage_path = temp.path().join("auth-sessions.json"); + fs::create_dir_all(&bb_home).expect("create Apps process bb home"); + fs::write(bb_home.join("config.yaml"), "org: test\n").expect("write Apps process config"); + let service_url = format!("{}/api/goose", auth_server.base_url); + let mut hasher = Sha256::new(); + hasher.update(b"default"); + hasher.update([0]); + hasher.update(service_url.as_bytes()); + let storage_key = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + fs::write( + &storage_path, + serde_json::to_vec_pretty(&json!({ + storage_key: { + "sessionCredential": credential, + "expiresAt": "2099-01-01T00:00:00Z" + } + })) + .expect("serialize Apps process storage"), + ) + .expect("write Apps process storage"); + let argv = std::iter::once("bb") + .chain(args.iter().copied()) + .map(str::to_string) + .collect::>(); + let mut command = ProcessCommand::new(std::env::current_exe().expect("current test exe")); + command + .args([ + "--exact", + "bb::apps::tests::bb_apps_e2e_process_helper", + "--nocapture", + ]) + .env("BB_APPS_E2E_ARGS", serde_json::to_string(&argv).unwrap()) + .env(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR, &control_plane.base_url) + .env("BB_HOME", bb_home) + .env("BB_AUTH_STORAGE", "file") + .env("BB_AUTH_STORAGE_FILE", storage_path) + .env("KGOOSE_BASE_URL", &auth_server.base_url) + .env_remove("BB_SKILLS_PROFILE") + .env_remove("KGOOSE_PLAYPEN"); + (temp, command) + } + + fn process_stdout(output: &std::process::Output) -> String { + let stdout = String::from_utf8(output.stdout.clone()).expect("Apps process stdout UTF-8"); + let start = stdout + .find(PROCESS_STDOUT_BEGIN) + .expect("Apps process stdout begin marker") + + PROCESS_STDOUT_BEGIN.len(); + let end = stdout[start..] + .find(PROCESS_STDOUT_END) + .map(|offset| start + offset) + .expect("Apps process stdout end marker"); + stdout[start..end].trim().to_string() + } + + fn assert_process_auth(request: &ProcessRequest, credential: &str) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/api/goose/v1/auth/me"); + assert_eq!( + request + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(credential) + ); + } + + fn assert_process_control_plane( + request: &ProcessRequest, + method: &str, + path: &str, + credential: &str, + ) { + assert_eq!(request.method, method); + assert_eq!(request.path, path); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(format!("BBIdentity {credential}").as_str()) + ); + for forbidden in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-user", + "x-forwarded-workspace-id", + ] { + assert!(!request.headers.contains_key(forbidden)); + } + } + + #[test] + fn bb_apps_contract_process_covers_auth_dispatch_output_and_redaction() { + let credential = "contract.session+credential"; + let contract = json!({ + "ok": true, + "contract_version": "2026-06-30", + "reflected": credential, + "nested": {"message": format!("prefix {credential} suffix")} + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(contract)]); + let (_temp, mut command) = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "contract", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps contract process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = process_stdout(&output); + assert!(!stdout.contains(credential)); + let value = serde_json::from_str::(&stdout).expect("parse contract process output"); + assert_eq!(value["contract_version"], "2026-06-30"); + assert_eq!(value["reflected"], "[REDACTED]"); + assert_eq!(value["nested"]["message"], "prefix [REDACTED] suffix"); + let auth_requests = auth_server.finish(); + let control_requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane(&control_requests[0], "GET", APPS_CONTRACT_PATH, credential); + } + + #[test] + fn bb_apps_create_process_runs_plan_and_initialize() { + let credential = "create.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan.clone()), + ProcessResponse::json(initialized.clone()), + ]); + let (_temp, mut command) = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + "fetch-js", + "--persistence", + "sqlite", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["app_id"], "merchant-lookup-2"); + assert_eq!(value["initialized"], true); + assert_eq!(value["plan"], plan); + assert_eq!(value["initialize"], initialized); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 2); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_eq!( + requests[0].body, + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": "fetch-js", + "persistence": "sqlite", + "client_version": "0.2.0" + }) + ); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + } + + #[test] + fn bb_apps_create_process_skips_unrequested_initialize() { + let credential = "existing.session+credential"; + let plan = json!({ + "app_id": "existing-app", + "external_url": "https://existing-app--bpsites.example/", + "initialize": {"required": false, "recommended": false} + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(plan.clone())]); + let (_temp, mut command) = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "existing-app", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(output.status.success()); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["app_id"], "existing-app"); + assert_eq!(value["initialized"], false); + assert_eq!(value["initialize"], Value::Null); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + } + + #[test] + fn bb_apps_deploy_process_uploads_multipart_artifact() { + let credential = "deploy.session+credential"; + let deployed = json!({ + "ok": true, + "app_id": "merchant-lookup", + "version_id": "ver-123", + "deployment_id": "dpl-123" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(deployed.clone())]); + let temp = tempfile::tempdir().expect("create deploy process temp directory"); + let artifact = temp.path().join("prepared-app.tar.gz"); + fs::write(&artifact, "test-hotpod-artifact-marker").expect("write deploy artifact"); + let artifact_text = artifact.to_str().expect("artifact path UTF-8"); + let (_config_temp, mut command) = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "deploy", + "merchant-lookup", + artifact_text, + "--environment", + "production", + "--version-id", + "ver-123", + "--deployment-id", + "dpl-123", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps deploy process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse deploy process output"), + deployed + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "POST", + "/v1/agent/apps/merchant-lookup/deploy", + credential, + ); + let body = String::from_utf8_lossy(&requests[0].body_bytes); + for expected in [ + "test-hotpod-artifact-marker", + "name=\"environment\"\r\n\r\nproduction", + "name=\"version_id\"\r\n\r\nver-123", + "name=\"deployment_id\"\r\n\r\ndpl-123", + ] { + assert!(body.contains(expected), "multipart omitted {expected:?}"); } } @@ -815,7 +1300,10 @@ mod tests { "1.0.0", Style::new(true, false, false), timeout, - Box::new(LoopbackTestTransport::new(base_url, timeout)), + Box::new( + LoopbackTestTransport::new(base_url, timeout) + .expect("build loopback test transport"), + ), ) .expect("build test control-plane client") } diff --git a/bb-cli/src/lib.rs b/bb-cli/src/lib.rs index c331fda34..1463f889f 100644 --- a/bb-cli/src/lib.rs +++ b/bb-cli/src/lib.rs @@ -109,6 +109,10 @@ fn run_agent_tools() -> Result<()> { fn run_bb() -> Result<()> { let argv = std::env::args().collect::>(); + run_bb_with_argv(argv) +} + +fn run_bb_with_argv(argv: Vec) -> Result<()> { let raw_args = &argv[1..]; if raw_args.first().map(String::as_str) == Some(TOOLS_COMMAND_NAME) { diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 0be41015d..52267b055 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -1,15 +1,12 @@ //! End-to-end tests for the `bb` binary (skills marketplace + bb-specific //! surfaces). The sq/agent-tools CLI suite lives in `cli_e2e.rs`; shared mock //! server infrastructure lives in `common/`. - mod common; use std::fs; use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; -#[cfg(feature = "apps-e2e-test")] -use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -3620,186 +3617,20 @@ fn bb_skills_doctor_offline_reports_server_failure() { const APPROVED_APPS_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; -#[cfg(feature = "apps-e2e-test")] -struct AppsHttpsMockServer { - address: String, - requests: Arc>>, - handle: Option>, -} - -#[cfg(feature = "apps-e2e-test")] -impl AppsHttpsMockServer { - fn start(responses: Vec) -> Self { - let server = tiny_http::Server::https( - "127.0.0.1:0", - tiny_http::SslConfig { - certificate: include_bytes!("fixtures/apps-e2e-server.pem").to_vec(), - private_key: include_bytes!("fixtures/apps-e2e-server-key.pem").to_vec(), - }, - ) - .expect("bind Apps HTTPS mock server"); - let address = server - .server_addr() - .to_ip() - .expect("Apps HTTPS mock IP address") - .to_string(); - let requests = Arc::new(Mutex::new(Vec::new())); - let thread_requests = Arc::clone(&requests); - let handle = thread::spawn(move || { - for response in responses { - let mut request = server - .recv_timeout(Duration::from_secs(10)) - .expect("receive Apps HTTPS mock request") - .expect("Apps HTTPS mock request before timeout"); - let method = request.method().as_str().to_string(); - let path = request.url().to_string(); - let headers = request - .headers() - .iter() - .map(|header| { - ( - header.field.as_str().to_string().to_ascii_lowercase(), - header.value.as_str().to_string(), - ) - }) - .collect::>(); - let mut body_bytes = Vec::new(); - request - .as_reader() - .read_to_end(&mut body_bytes) - .expect("read Apps HTTPS mock request"); - let body = if headers - .get("content-type") - .is_some_and(|value| value.starts_with("application/json")) - { - serde_json::from_slice(&body_bytes).expect("parse Apps HTTPS JSON request") - } else { - Value::Null - }; - thread_requests - .lock() - .expect("lock Apps HTTPS requests") - .push(common::RecordedRequest { - method, - path, - headers, - body, - body_bytes, - }); - - let mut reply = - tiny_http::Response::from_data(response.body).with_status_code(response.status); - for (name, value) in response.headers { - reply.add_header( - tiny_http::Header::from_bytes(name, value) - .expect("build Apps HTTPS response header"), - ); - } - request - .respond(reply) - .expect("respond to Apps HTTPS request"); - } - }); - Self { - address, - requests, - handle: Some(handle), - } - } - - fn finish(mut self) -> Vec { - self.handle - .take() - .expect("Apps HTTPS mock server handle") - .join() - .expect("join Apps HTTPS mock server"); - self.requests - .lock() - .expect("lock Apps HTTPS requests") - .clone() - } -} - -#[cfg(feature = "apps-e2e-test")] -fn apps_auth_me_response() -> MockResponse { - MockResponse::json(json!({ - "subject": "auth0|apps-user", - "email": "apps@example.com", - "name": "Apps User", - "expires_at": "2099-01-01T00:00:00Z", - "workspaces": {"active": [{"name": "Test Workspace"}]} - })) -} - -#[cfg(feature = "apps-e2e-test")] -fn configured_apps_command( - auth_server: &MockServer, - control_plane: &AppsHttpsMockServer, - temp: &Path, - credential: &str, -) -> std::process::Command { - let bb_home = temp.join("bb-home"); - let storage_path = temp.join("auth-sessions.json"); - write_bb_org_config(&bb_home, "test"); - write_browser_auth_session( - &storage_path, - &auth_server.base_url, - credential, - "2099-01-01T00:00:00Z", - ); - let mut command = bb_command(); - command - .env("BB_HOME", bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", storage_path) - .env("KGOOSE_BASE_URL", &auth_server.base_url) - .env("BB_APPS_E2E_RESOLVE_ADDR", &control_plane.address); - command -} - -#[cfg(feature = "apps-e2e-test")] -fn assert_apps_auth_request(request: &common::RecordedRequest, credential: &str) { - assert_eq!(request.method, "GET"); - assert_eq!(request.path, "/api/goose/v1/auth/me"); - assert_eq!( - request - .headers - .get("x-bb-session-credential") - .map(String::as_str), - Some(credential) - ); - assert!(!request.headers.contains_key("authorization")); -} - -#[cfg(feature = "apps-e2e-test")] -fn assert_apps_control_plane_request( - request: &common::RecordedRequest, - method: &str, - path: &str, - credential: &str, - client_version: &str, -) { - let expected_authorization = format!("BBIdentity {credential}"); - assert_eq!(request.method, method); - assert_eq!(request.path, path); - assert_eq!( - request.headers.get("authorization").map(String::as_str), - Some(expected_authorization.as_str()) - ); - assert_eq!( - request - .headers - .get("x-hotpod-agent-client-version") - .map(String::as_str), - Some(client_version) - ); +#[test] +fn bb_shipped_artifact_contains_no_apps_test_transport() { + let binary = fs::read(env!("CARGO_BIN_EXE_bb")).expect("read shipped bb test artifact"); for forbidden in [ - "cookie", - "x-bb-session-credential", - "x-forwarded-user", - "x-forwarded-workspace-id", + "BB_APPS_E2E_CONTROL_PLANE_URL", + "BB_APPS_E2E_RESOLVE_ADDR", + "Berd Apps E2E Test CA", ] { - assert!(!request.headers.contains_key(forbidden)); + assert!( + !binary + .windows(forbidden.len()) + .any(|window| window == forbidden.as_bytes()), + "shipped bb artifact contained Apps test-only material {forbidden:?}" + ); } } @@ -3827,272 +3658,6 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } } -#[cfg(feature = "apps-e2e-test")] -#[test] -fn bb_apps_contract_verifies_and_uses_the_same_session() { - let credential = "contract.session+credential"; - let contract = json!({ - "ok": true, - "contract_version": "2026-06-30", - "supported_operations": [{"method": "GET", "path": "/v1/agent/contract"}] - }); - let auth_server = MockServer::start(vec![apps_auth_me_response()]); - let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(contract.clone())]); - let temp = temp_test_dir("bb-apps-contract-success"); - - let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) - .args([ - "apps", - "contract", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps contract"); - let auth_requests = auth_server.finish(); - let control_plane_requests = control_plane.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse contract output"), - contract - ); - assert!(!stdout.contains(credential)); - assert!(!stderr.contains(credential)); - assert_eq!(auth_requests.len(), 1); - assert_apps_auth_request(&auth_requests[0], credential); - assert_eq!(control_plane_requests.len(), 1); - assert_apps_control_plane_request( - &control_plane_requests[0], - "GET", - "/v1/agent/contract", - credential, - "0.2.0", - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[cfg(feature = "apps-e2e-test")] -#[test] -fn bb_apps_create_runs_plan_and_initialize_end_to_end() { - let credential = "create.session+credential"; - let plan = json!({ - "app_id": "merchant-lookup", - "display_name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default", - "initialize": {"required": true, "recommended": false} - }); - let initialized = json!({ - "app_id": "merchant-lookup-2", - "external_url": "https://merchant-lookup-2--bpsites.example/" - }); - let auth_server = MockServer::start(vec![apps_auth_me_response()]); - let control_plane = AppsHttpsMockServer::start(vec![ - MockResponse::json(plan.clone()), - MockResponse::json(initialized.clone()), - ]); - let temp = temp_test_dir("bb-apps-create-success"); - - let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) - .args([ - "apps", - "create", - "--app-id", - "merchant-lookup", - "--name", - "Merchant Lookup", - "--environment", - "staging", - "--runtime-profile", - "fetch-js", - "--persistence", - "sqlite", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps create"); - let auth_requests = auth_server.finish(); - let requests = control_plane.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["app_id"], json!("merchant-lookup-2")); - assert_eq!(response["initialized"], json!(true)); - assert_eq!(response["plan"], plan); - assert_eq!(response["initialize"], initialized); - assert_apps_auth_request(&auth_requests[0], credential); - assert_eq!(requests.len(), 2); - assert_apps_control_plane_request( - &requests[0], - "POST", - "/v1/agent/apps/plan", - credential, - "0.2.0", - ); - assert_eq!( - requests[0].body, - json!({ - "app_id": "merchant-lookup", - "name": "Merchant Lookup", - "environment": "staging", - "runtime_profile": "fetch-js", - "persistence": "sqlite", - "client_version": "0.2.0" - }) - ); - assert_apps_control_plane_request( - &requests[1], - "POST", - "/v1/agent/apps/merchant-lookup/initialize", - credential, - "0.2.0", - ); - assert_eq!( - requests[1].body, - json!({ - "name": "Merchant Lookup", - "environment": "staging", - "persistence": "sqlite", - "runtime_class": "default" - }) - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[cfg(feature = "apps-e2e-test")] -#[test] -fn bb_apps_create_skips_initialize_when_plan_does_not_request_it() { - let credential = "existing.session+credential"; - let plan = json!({ - "app_id": "existing-app", - "external_url": "https://existing-app--bpsites.example/", - "initialize": {"required": false, "recommended": false} - }); - let auth_server = MockServer::start(vec![apps_auth_me_response()]); - let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(plan.clone())]); - let temp = temp_test_dir("bb-apps-create-existing-success"); - - let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) - .args([ - "apps", - "create", - "--app-id", - "existing-app", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps create without initialize"); - let auth_requests = auth_server.finish(); - let requests = control_plane.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - let response = serde_json::from_str::(&stdout).expect("parse create output"); - assert_eq!(response["app_id"], json!("existing-app")); - assert_eq!(response["initialized"], json!(false)); - assert_eq!(response["initialize"], Value::Null); - assert_eq!(response["plan"], plan); - assert_apps_auth_request(&auth_requests[0], credential); - assert_eq!(requests.len(), 1); - assert_apps_control_plane_request( - &requests[0], - "POST", - "/v1/agent/apps/plan", - credential, - "0.2.0", - ); - fs::remove_dir_all(temp).expect("remove temp dir"); -} - -#[cfg(feature = "apps-e2e-test")] -#[test] -fn bb_apps_deploy_uploads_multipart_artifact_end_to_end() { - let credential = "deploy.session+credential"; - let deployed = json!({ - "ok": true, - "app_id": "merchant-lookup", - "version_id": "ver-123", - "deployment_id": "dpl-123" - }); - let auth_server = MockServer::start(vec![apps_auth_me_response()]); - let control_plane = AppsHttpsMockServer::start(vec![MockResponse::json(deployed.clone())]); - let temp = temp_test_dir("bb-apps-deploy-success"); - let artifact_path = temp.join("prepared-app.tar.gz"); - fs::write(&artifact_path, "test-hotpod-artifact-marker").expect("write deploy artifact"); - - let output = configured_apps_command(&auth_server, &control_plane, &temp, credential) - .args([ - "apps", - "deploy", - "merchant-lookup", - artifact_path.to_str().expect("artifact path text"), - "--environment", - "production", - "--version-id", - "ver-123", - "--deployment-id", - "dpl-123", - "--base-url", - APPROVED_APPS_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]) - .output() - .expect("run bb apps deploy"); - let auth_requests = auth_server.finish(); - let requests = control_plane.finish(); - let (stdout, stderr) = output_text(&output); - - assert!(output.status.success(), "stderr was: {stderr}"); - assert_eq!( - serde_json::from_str::(&stdout).expect("parse deploy output"), - deployed - ); - assert_apps_auth_request(&auth_requests[0], credential); - assert_eq!(requests.len(), 1); - let request = &requests[0]; - assert_apps_control_plane_request( - request, - "POST", - "/v1/agent/apps/merchant-lookup/deploy", - credential, - "0.2.0", - ); - assert!(request - .headers - .get("content-type") - .is_some_and(|value| value.starts_with("multipart/form-data; boundary="))); - let body = String::from_utf8_lossy(&request.body_bytes); - for expected in [ - "test-hotpod-artifact-marker", - "name=\"environment\"\r\n\r\nproduction", - "name=\"version_id\"\r\n\r\nver-123", - "name=\"deployment_id\"\r\n\r\ndpl-123", - ] { - assert!( - body.contains(expected), - "multipart body omitted {expected:?}" - ); - } - fs::remove_dir_all(temp).expect("remove temp dir"); -} - #[test] fn bb_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { let kgoose = MockServer::start(vec![]); diff --git a/bb-cli/tests/common/mod.rs b/bb-cli/tests/common/mod.rs index f31ccac86..3c99f5861 100644 --- a/bb-cli/tests/common/mod.rs +++ b/bb-cli/tests/common/mod.rs @@ -197,7 +197,6 @@ pub fn bb_command() -> Command { .env_remove("BB_KGOOSE_PLAYPEN") .env_remove("BB_AUTH_STORAGE") .env_remove("BB_AUTH_STORAGE_FILE") - .env_remove("BB_APPS_E2E_RESOLVE_ADDR") .env_remove("KGOOSE_BASE_URL") .env_remove("KGOOSE_DEBUG") .env_remove("KGOOSE_PLAYPEN") diff --git a/bb-cli/tests/fixtures/apps-e2e-ca.pem b/bb-cli/tests/fixtures/apps-e2e-ca.pem deleted file mode 100644 index c3d3a53b0..000000000 --- a/bb-cli/tests/fixtures/apps-e2e-ca.pem +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDMTCCAhmgAwIBAgIUXXVunM6gfBEjusyIqFCiDcKCxVQwDQYJKoZIhvcNAQEL -BQAwIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBUZXN0IENBMB4XDTI2MDgxODAw -NTc1NFoXDTM2MDgxNTAwNTc1NFowIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBU -ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1yQz5c4CDpNh -MhdqIiuEBYmOkv7JzB0ihKqAUV7f+yjNnwT1kLpO5Chv1CARabUuJTV+KJ1lh+hV -drUQJpIUp+y90zmG5yi6Y5I/bulV3V4moHLsP31qLkcEsVF93RnbKNCeUMwbNj/A -tDc0hbmLpLYyr3qbI8UkHviAF0VR5vsnounbK+tpzrcM0z5YnZtfjrM09FvfS4bD -/aHI7BGoVfh4l1Ml3GkJ2b7CTtyLxFvT2NcY03oZrbr7ozp6OABugliEMABcEz3R -dOSrnA3Bj9gQUelFu0z6WJSv389n3BJbwTvwbYbsgxQixpf7C43WC/kAMxTXATX3 -8VlClDqj/QIDAQABo2MwYTAdBgNVHQ4EFgQUix4g3fG+mcVvxxqghx42++EGPREw -HwYDVR0jBBgwFoAUix4g3fG+mcVvxxqghx42++EGPREwDwYDVR0TAQH/BAUwAwEB -/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAIi1DnSKgYhJeujJ -FLL4iKobdxdTg1LErlmSh0tiIdJJwruYo00ZH4HlYQJeosOW7mclUFjQdKpmOQ6U -RXwrJ8jkvjaEt3lRA3HYwqYEdUZJmqe++5OdDlh88s6sQgm0VNZtchzlHZqGLqAI -/ganbg/To+ENC+0Cqz7nwr5YOFc96tzsOVaN0l4HxxxelsFhi3eJLFVAeR2cL6gm -SVyyqZEFYhG5DnL3/7TCxB5RJ2Vam3E/ns9NaBN6NtxMfYaaYjaG28ZHi7FegAtv -SGTTzyrGCTD/tpa+ZL4nwe6fMcd/CItb/RbyQTfa+vn3LkV7G3rk3iKS32YrGcQr -NpNdUKQ= ------END CERTIFICATE----- diff --git a/bb-cli/tests/fixtures/apps-e2e-server-key.pem b/bb-cli/tests/fixtures/apps-e2e-server-key.pem deleted file mode 100644 index 134382f2f..000000000 --- a/bb-cli/tests/fixtures/apps-e2e-server-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDXBvDGcqOwpZ6/ -z+eHwy8EPfShppoAcytP7oqXRrDkiuwT+beEEGNFWpDeLUY+qpy+DHVJ7xbmaRWU -YwMMSMHWCzOX0sYaS9EMHZrpA2hOUoiVdwpKvIG8gi+7dZsqaFViHRvvoJyS352l -ih5s4K68OaS4TVdd/Zyt1bUsLAWbEd/iwMR9km4YdidMw+0akPEkYzPtsO6ukCwO -ZgmiRiJuR7hVLCQPLTrl2CjD6qIYI5y+eDG0QT2euz2IKfxxdgdpahMX2bKyt8oj -q53td2p79dHQ1YwFNdgjAtTKMSSO06NByd0sUONWVzj/pHRgSIxmEGpEw3Zg6ZX9 -TryDoS2nAgMBAAECggEAZaA0H7aCwrQkCUe7h6CqEfkuK1BQLLJB4C8/dSvF4t39 -oZs+Lr6IDHk3SqpfLrL4DaJZtK25RwCXYGBDSoUAh6cXpUPKuRboIC/FzSb9HzdG -sk1modfiATQOVyzIPwy8ffiAAYsJNSlWmqxioNa3/uHHhguXpSZ97HK6g7vykkzM -kvUTCQEuvmeauC2gUqQAUZjXb7gC5NYf+G5nnn90TSKWNgLD+KP5YrozTqy1GKEB -MvpQeyj1w8dsZXqe7cWeSTgov4nsnMfOd1M2O3hSet7rmgon/UdW09VMUoFs8n9A -/mhgXA29SLwqtztZAmqfqOx7sjDIOz00nTq1ST/UgQKBgQDunaKErhr8sb2dNaVy -/DL+jN82k6VbmtPQSotwP+sMheF9r3zjo4IgO8rUsdKHZWkRut8tQ/XJES+R1UlK -O8Kpht0sVR9greDPsTwIN07Ht8c9+BQnUH/lQIdhft4abJFV4vt6L/TuNfGTFzuR -/UfYS2uPvFvgfL6srh4wbBmXoQKBgQDmsVwFm4+DQc53JhNqENe/zGsi8+1vbnGn -GqqUQKAw3QT0msy9xb2iUfNcB+lb/0L/4Zl8Eg/XKiyKBQo3UpLzcQCP4aqZX4DY -xBz888coqnuWPFFNonvUFGKe/BCtGomu1X8rKRJwBeupSPHAK+2HMiHEA270U7M8 -RQ/2XRQgRwKBgQDoexUYiDkq8lF3lgj4mtdkQwRHPFrjgVnVmot4dg4gSWCFADGB -6JCjrx3TVN11pUxVReijRY92sxPR1iht9wOWABwFUXocy8w5DskaiChtVZT9v3KD -S18QkWpVhzIGNLj1IQ064vaUEGKpmP0lI8yX5AOMK0yoz2FHBO3M58WXgQKBgD7E -82zzLtFgDnWM/qtVed7OGDiidnBjdLkrIE7GZs/k03xawmrAayDHe5gG7xABHJHT -KJgBsh2xc/z58hWreiCTFrwPgwPIYJ6afei1y/LcsFPohZbCJz9FbLAllcQD/IJ9 -xORRgJrKgZzGJEFNsouesGFNLdt9Cr/Tasx19wvxAoGBAMkuSpmbkZpuRbUJ4OIu -Sw4H+tXPal98Y7qypzSlx+R8BZEwb6VQpLwpNlvXA2jeITGwIxf4rh6y5YNwSLqR -eAqC9GKe4AmedjffgaieFBygaHjoRkcCxRd1I0QCD9LE8n8ZpNcKWQhUFZOfC5hR -D/ig96SiIfXYrLBApUxAGLH0 ------END PRIVATE KEY----- diff --git a/bb-cli/tests/fixtures/apps-e2e-server.pem b/bb-cli/tests/fixtures/apps-e2e-server.pem deleted file mode 100644 index 9888c7c8f..000000000 --- a/bb-cli/tests/fixtures/apps-e2e-server.pem +++ /dev/null @@ -1,22 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDpjCCAo6gAwIBAgIUGz3Kcf44bBi5n28jky6JgFJUPW4wDQYJKoZIhvcNAQEL -BQAwIDEeMBwGA1UEAwwVQmVyZCBBcHBzIEUyRSBUZXN0IENBMB4XDTI2MDgxODAw -NTc1NFoXDTM2MDgxNTAwNTc1NFowLzEtMCsGA1UEAwwkY29tcG9zZS1jdHJsLnRl -c3QuYmxvY2tzdGFnaW5nLmJ1aWxkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEA1wbwxnKjsKWev8/nh8MvBD30oaaaAHMrT+6Kl0aw5IrsE/m3hBBjRVqQ -3i1GPqqcvgx1Se8W5mkVlGMDDEjB1gszl9LGGkvRDB2a6QNoTlKIlXcKSryBvIIv -u3WbKmhVYh0b76Cckt+dpYoebOCuvDmkuE1XXf2crdW1LCwFmxHf4sDEfZJuGHYn -TMPtGpDxJGMz7bDurpAsDmYJokYibke4VSwkDy065dgow+qiGCOcvngxtEE9nrs9 -iCn8cXYHaWoTF9mysrfKI6ud7Xdqe/XR0NWMBTXYIwLUyjEkjtOjQcndLFDjVlc4 -/6R0YEiMZhBqRMN2YOmV/U68g6EtpwIDAQABo4HIMIHFMAwGA1UdEwEB/wQCMAAw -DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMFAGA1UdEQRJMEeC -JGNvbXBvc2UtY3RybC50ZXN0LmJsb2Nrc3RhZ2luZy5idWlsZIIfY29tcG9zZS1j -dHJsLmFwcC5idWlsZGVybGFiLnh5ejAdBgNVHQ4EFgQUb2y+CEXraCGai13C5lDm -ChdqHP0wHwYDVR0jBBgwFoAUix4g3fG+mcVvxxqghx42++EGPREwDQYJKoZIhvcN -AQELBQADggEBAI3aldblEEGOWTG3AT4o9i7slMB3MR3+/s5ZMZTtRGNWkVNGnAsL -HSvMyr7xTyRgBvaK2vjjSOU8fMftFin4i9qFvsSc6UoGlH+dIrjfj2kRw16kQLJs -HnOoV1uxrY5X4+edyjqcJJ4hLRtZmYcjvunOHsL2SolWAqKtqUZB6diYNeHrwILD -amSuyLgTSJ5PzS+7QNyOIEMGpvQH020JvYLCTthKv6yy/77UtCuaRpx7vauu3sSj -QMg7ZJwoqfikw6pvUI5cSvzbuv6bh0JwVus21rKepamtT/jfBLu+w6AUWNwJ4Zhu -1+vq+YGcSYNSGu7tow7yZvG0KnR7OLWptg4= ------END CERTIFICATE----- From 996fc2550e20f4a69a418d52e1b597eaea709aa4 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Mon, 17 Aug 2026 23:13:36 -0400 Subject: [PATCH 10/10] Isolate the Apps process test harness --- bb-cli/src/bb/apps.rs | 198 +++++++++++++++++++++++++++++++---------- bb-cli/tests/bb_e2e.rs | 2 + 2 files changed, 151 insertions(+), 49 deletions(-) diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index d63fa35bd..2b925e79f 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -40,6 +40,10 @@ const APPS_BASE_URL_ENV_VAR: &str = "BB_APPS_CONTROL_PLANE_URL"; const APPS_CLIENT_VERSION_ENV_VAR: &str = "BB_APPS_CLIENT_VERSION"; #[cfg(test)] const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BB_APPS_E2E_CONTROL_PLANE_URL"; +#[cfg(test)] +const APPS_E2E_AUTH_URL_ENV_VAR: &str = "BB_APPS_E2E_AUTH_URL"; +#[cfg(test)] +const APPS_E2E_CREDENTIAL_ENV_VAR: &str = "BB_APPS_E2E_CREDENTIAL"; const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; @@ -419,7 +423,7 @@ impl LoopbackTestTransport { fn new(base_url: &str, timeout: Duration) -> Result { Ok(Self { client: build_auth_http_client(timeout)?, - base_url: url::Url::parse(base_url).context("parse Apps E2E loopback URL")?, + base_url: validate_apps_e2e_loopback_url(base_url, APPS_E2E_CONTROL_PLANE_URL_ENV_VAR)?, }) } } @@ -622,7 +626,8 @@ impl ControlPlaneClient { } let mut value = serde_json::from_str(&body) .with_context(|| format!("parse Apps Platform {method} {path} response"))?; - redact_json_value(&mut value, credential); + redact_json_value(&mut value, credential) + .with_context(|| format!("sanitize Apps Platform {method} {path} response"))?; Ok(value) } @@ -671,24 +676,51 @@ fn build_control_plane_http_client(timeout: Duration) -> Result { .context("build Apps Platform control-plane HTTP client") } -fn redact_json_value(value: &mut Value, credential: &ComposeSessionCredential) { +fn redact_json_value(value: &mut Value, credential: &ComposeSessionCredential) -> Result<()> { match value { Value::String(text) => *text = credential.redact(text), Value::Array(items) => { for item in items { - redact_json_value(item, credential); + redact_json_value(item, credential)?; } } Value::Object(object) => { - let mut sanitized = Map::new(); - for (key, mut value) in std::mem::take(object) { - redact_json_value(&mut value, credential); - sanitized.insert(credential.redact(&key), value); + for (key, value) in object { + if credential.redact(key) != *key { + anyhow::bail!( + "Apps Platform response contained the session credential in an object key" + ); + } + redact_json_value(value, credential)?; } - *object = sanitized; } Value::Null | Value::Bool(_) | Value::Number(_) => {} } + Ok(()) +} + +#[cfg(test)] +fn validate_apps_e2e_loopback_url(value: &str, name: &str) -> Result { + let url = url::Url::parse(value).with_context(|| format!("parse {name}"))?; + let loopback_ip = match url.host() { + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + Some(url::Host::Domain(_)) | None => false, + }; + if url.scheme() != "http" + || !loopback_ip + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + anyhow::bail!( + "{name} must be an HTTP loopback IP origin with an explicit port and no userinfo, path, query, or fragment" + ); + } + Ok(url) } fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result { @@ -946,33 +978,23 @@ mod tests { args.to_str().expect("BB_APPS_E2E_ARGS must be UTF-8"), ) .expect("parse BB_APPS_E2E_ARGS"); - println!("{PROCESS_STDOUT_BEGIN}"); - crate::run_bb_with_argv(args).expect("run bb Apps process command"); - println!("{PROCESS_STDOUT_END}"); - } - - fn process_auth_response() -> ProcessResponse { - ProcessResponse::json(json!({ - "subject": "auth0|apps-user", - "email": "apps@example.com", - "name": "Apps User", - "expires_at": "2099-01-01T00:00:00Z", - "workspaces": {"active": [{"name": "Test Workspace"}]} - })) - } - - fn process_command( - auth_server: &ProcessServer, - control_plane: &ProcessServer, - args: &[&str], - credential: &str, - ) -> (tempfile::TempDir, ProcessCommand) { - let temp = tempfile::tempdir().expect("create Apps process temp directory"); + let auth_url = std::env::var(APPS_E2E_AUTH_URL_ENV_VAR) + .expect("Apps E2E helper requires an explicit auth URL"); + let auth_url = validate_apps_e2e_loopback_url(&auth_url, APPS_E2E_AUTH_URL_ENV_VAR) + .expect("validate Apps E2E auth URL"); + let credential = std::env::var(APPS_E2E_CREDENTIAL_ENV_VAR) + .expect("Apps E2E helper requires an explicit synthetic credential"); + assert!( + credential.starts_with("apps-e2e-only."), + "Apps E2E helper accepts only synthetic test credentials" + ); + let temp = tempfile::tempdir().expect("create isolated Apps E2E home"); let bb_home = temp.path().join("bb-home"); let storage_path = temp.path().join("auth-sessions.json"); - fs::create_dir_all(&bb_home).expect("create Apps process bb home"); - fs::write(bb_home.join("config.yaml"), "org: test\n").expect("write Apps process config"); - let service_url = format!("{}/api/goose", auth_server.base_url); + fs::create_dir_all(&bb_home).expect("create isolated Apps E2E bb home"); + fs::write(bb_home.join("config.yaml"), "org: test\n") + .expect("write isolated Apps E2E config"); + let service_url = format!("{}/api/goose", auth_url.as_str().trim_end_matches('/')); let mut hasher = Sha256::new(); hasher.update(b"default"); hasher.update([0]); @@ -990,9 +1012,37 @@ mod tests { "expiresAt": "2099-01-01T00:00:00Z" } })) - .expect("serialize Apps process storage"), + .expect("serialize isolated Apps E2E storage"), ) - .expect("write Apps process storage"); + .expect("write isolated Apps E2E storage"); + std::env::set_var("BB_HOME", &bb_home); + std::env::set_var("BB_AUTH_STORAGE", "file"); + std::env::set_var("BB_AUTH_STORAGE_FILE", &storage_path); + std::env::set_var("KGOOSE_BASE_URL", auth_url.as_str()); + std::env::remove_var("BB_SKILLS_PROFILE"); + std::env::remove_var("KGOOSE_PLAYPEN"); + println!("{PROCESS_STDOUT_BEGIN}"); + crate::run_bb_with_argv(args).expect("run bb Apps process command"); + println!("{PROCESS_STDOUT_END}"); + } + + fn process_auth_response() -> ProcessResponse { + ProcessResponse::json(json!({ + "subject": "auth0|apps-user", + "email": "apps@example.com", + "name": "Apps User", + "expires_at": "2099-01-01T00:00:00Z", + "workspaces": {"active": [{"name": "Test Workspace"}]} + })) + } + + fn process_command( + auth_server: &ProcessServer, + control_plane: &ProcessServer, + args: &[&str], + credential: &str, + ) -> ProcessCommand { + assert!(credential.starts_with("apps-e2e-only.")); let argv = std::iter::once("bb") .chain(args.iter().copied()) .map(str::to_string) @@ -1006,13 +1056,15 @@ mod tests { ]) .env("BB_APPS_E2E_ARGS", serde_json::to_string(&argv).unwrap()) .env(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR, &control_plane.base_url) - .env("BB_HOME", bb_home) - .env("BB_AUTH_STORAGE", "file") - .env("BB_AUTH_STORAGE_FILE", storage_path) - .env("KGOOSE_BASE_URL", &auth_server.base_url) + .env(APPS_E2E_AUTH_URL_ENV_VAR, &auth_server.base_url) + .env(APPS_E2E_CREDENTIAL_ENV_VAR, credential) + .env_remove("BB_HOME") + .env_remove("BB_AUTH_STORAGE") + .env_remove("BB_AUTH_STORAGE_FILE") + .env_remove("KGOOSE_BASE_URL") .env_remove("BB_SKILLS_PROFILE") .env_remove("KGOOSE_PLAYPEN"); - (temp, command) + command } fn process_stdout(output: &std::process::Output) -> String { @@ -1062,9 +1114,30 @@ mod tests { } } + #[test] + fn apps_e2e_destinations_require_explicit_http_loopback_ip_origins() { + for valid in ["http://127.0.0.1:1234", "http://[::1]:4321"] { + assert!(validate_apps_e2e_loopback_url(valid, "test URL").is_ok()); + } + for invalid in [ + "http://192.0.2.1:1234", + "https://127.0.0.1:1234", + "http://localhost:1234", + "http://user@127.0.0.1:1234", + "http://127.0.0.1:1234/path", + "http://127.0.0.1:1234/?query=yes", + "http://127.0.0.1:1234/#fragment", + "http://127.0.0.1", + ] { + let error = validate_apps_e2e_loopback_url(invalid, "test URL") + .expect_err("reject unsafe Apps E2E destination"); + assert!(error.to_string().contains("HTTP loopback IP origin")); + } + } + #[test] fn bb_apps_contract_process_covers_auth_dispatch_output_and_redaction() { - let credential = "contract.session+credential"; + let credential = "apps-e2e-only.contract.session+credential"; let contract = json!({ "ok": true, "contract_version": "2026-06-30", @@ -1073,7 +1146,7 @@ mod tests { }); let auth_server = ProcessServer::start(vec![process_auth_response()]); let control_plane = ProcessServer::start(vec![ProcessResponse::json(contract)]); - let (_temp, mut command) = process_command( + let mut command = process_command( &auth_server, &control_plane, &[ @@ -1108,7 +1181,7 @@ mod tests { #[test] fn bb_apps_create_process_runs_plan_and_initialize() { - let credential = "create.session+credential"; + let credential = "apps-e2e-only.create.session+credential"; let plan = json!({ "app_id": "merchant-lookup", "display_name": "Merchant Lookup", @@ -1126,7 +1199,7 @@ mod tests { ProcessResponse::json(plan.clone()), ProcessResponse::json(initialized.clone()), ]); - let (_temp, mut command) = process_command( + let mut command = process_command( &auth_server, &control_plane, &[ @@ -1189,7 +1262,7 @@ mod tests { #[test] fn bb_apps_create_process_skips_unrequested_initialize() { - let credential = "existing.session+credential"; + let credential = "apps-e2e-only.existing.session+credential"; let plan = json!({ "app_id": "existing-app", "external_url": "https://existing-app--bpsites.example/", @@ -1197,7 +1270,7 @@ mod tests { }); let auth_server = ProcessServer::start(vec![process_auth_response()]); let control_plane = ProcessServer::start(vec![ProcessResponse::json(plan.clone())]); - let (_temp, mut command) = process_command( + let mut command = process_command( &auth_server, &control_plane, &[ @@ -1230,7 +1303,7 @@ mod tests { #[test] fn bb_apps_deploy_process_uploads_multipart_artifact() { - let credential = "deploy.session+credential"; + let credential = "apps-e2e-only.deploy.session+credential"; let deployed = json!({ "ok": true, "app_id": "merchant-lookup", @@ -1243,7 +1316,7 @@ mod tests { let artifact = temp.path().join("prepared-app.tar.gz"); fs::write(&artifact, "test-hotpod-artifact-marker").expect("write deploy artifact"); let artifact_text = artifact.to_str().expect("artifact path UTF-8"); - let (_config_temp, mut command) = process_command( + let mut command = process_command( &auth_server, &control_plane, &[ @@ -1523,6 +1596,33 @@ mod tests { server_thread.join().expect("join request server"); } + #[test] + fn successful_response_rejects_secret_bearing_keys_without_collisions() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let secret = "reflected_key_session_credential_123456"; + let mut nested = Map::new(); + nested.insert(secret.to_string(), json!("secret-key value")); + nested.insert("[REDACTED]".to_string(), json!("existing value")); + let response_body = json!({"nested": Value::Object(nested)}).to_string(); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive request"); + request + .respond(Response::from_string(response_body)) + .expect("send reflected key response"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + + let error = client + .contract(&test_credential(secret)) + .expect_err("reject a successful response with the session in an object key"); + let message = format!("{error:#}"); + + assert!(message.contains("object key")); + assert!(!message.contains(secret)); + server_thread.join().expect("join request server"); + } + #[test] fn initialize_and_deploy_allow_delayed_rollout_responses() { assert!(CONTROL_PLANE_REQUEST_TIMEOUT > Duration::from_secs(2 * 60)); diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 52267b055..dba7d9a52 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -3622,6 +3622,8 @@ fn bb_shipped_artifact_contains_no_apps_test_transport() { let binary = fs::read(env!("CARGO_BIN_EXE_bb")).expect("read shipped bb test artifact"); for forbidden in [ "BB_APPS_E2E_CONTROL_PLANE_URL", + "BB_APPS_E2E_AUTH_URL", + "BB_APPS_E2E_CREDENTIAL", "BB_APPS_E2E_RESOLVE_ADDR", "Berd Apps E2E Test CA", ] {