From 776e81c8b87e1b3fa98cb2477e876b3c0a6f02ff Mon Sep 17 00:00:00 2001 From: Torsten Rudolf Date: Mon, 12 Oct 2026 14:45:55 +1100 Subject: [PATCH 1/2] feat: delegate bl tools authentication to Roxy on Blox Use the Blox-configured BL_TOOLS_BASE_URL origin without local organization configuration or session lookup. Omit local session and STS credentials in proxy mode, preserve tools API routing, and refuse redirects. Co-authored-by: Steve --- src/cli.rs | 13 ++- src/kgoose.rs | 33 ++++-- src/lib.rs | 55 +++++++++- src/runtime.rs | 1 + tests/bl_e2e.rs | 255 ++++++++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 5 +- 6 files changed, 345 insertions(+), 17 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 7430a1d..a02c068 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -104,6 +104,17 @@ fn is_global_switch_flag(arg: &str) -> bool { } pub fn bootstrap_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + bootstrap_args_with_default_base_url(args, None) +} + +pub fn bootstrap_args_with_default_base_url( + args: I, + default_base_url: Option<&str>, +) -> Result where I: IntoIterator, S: Into, @@ -146,7 +157,7 @@ where Ok(BootstrapArgs { base_url: resolve_base_url( env_base_url.as_deref(), - values.cli_base_url.as_deref(), + values.cli_base_url.as_deref().or(default_base_url), read_optional_env(IS_BLOX_ENV_VAR)?.as_deref(), read_optional_env(BLOX_ENVIRONMENT_ENV_VAR)?.as_deref(), ), diff --git a/src/kgoose.rs b/src/kgoose.rs index 0e8eead..b9d0e1f 100644 --- a/src/kgoose.rs +++ b/src/kgoose.rs @@ -29,6 +29,8 @@ pub struct KgooseConfig { pub goosemcp_playpen: Option, pub timeout_secs: f64, pub session_credential: Option, + /// Roxy authenticates the configured Blox tools origin. + pub proxy_auth: bool, } impl KgooseConfig { @@ -163,7 +165,11 @@ fn build_http_client(config: &KgooseConfig) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - if let Some(session_credential) = config.session_credential.as_deref() { + if let Some(session_credential) = config + .session_credential + .as_deref() + .filter(|_| !config.proxy_auth) + { headers.insert( SESSION_CREDENTIAL_HEADER, HeaderValue::from_str(session_credential) @@ -190,16 +196,18 @@ fn build_http_client(config: &KgooseConfig) -> Result { ); } - match env::var(STS_ACCESS_TOKEN_ENV_VAR) { - Ok(access_token) => { - headers.insert( - HeaderName::from_static("x-forwarded-identity-token"), - HeaderValue::from_str(&access_token) - .context("build x-forwarded-identity-token header")?, - ); + if !config.proxy_auth { + match env::var(STS_ACCESS_TOKEN_ENV_VAR) { + Ok(access_token) => { + headers.insert( + HeaderName::from_static("x-forwarded-identity-token"), + HeaderValue::from_str(&access_token) + .context("build x-forwarded-identity-token header")?, + ); + } + Err(std::env::VarError::NotPresent) => {} + Err(err) => anyhow::bail!("failed to read {STS_ACCESS_TOKEN_ENV_VAR}: {err}"), } - Err(env::VarError::NotPresent) => {} - Err(err) => anyhow::bail!("failed to read {STS_ACCESS_TOKEN_ENV_VAR}: {err}"), } debug_log(format!( @@ -212,6 +220,11 @@ fn build_http_client(config: &KgooseConfig) -> Result { )); ClientBuilder::new() + .redirect(if config.proxy_auth { + reqwest::redirect::Policy::none() + } else { + reqwest::redirect::Policy::default() + }) .default_headers(headers) .timeout(config.timeout()) .build() diff --git a/src/lib.rs b/src/lib.rs index f064738..c6b493f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,10 +15,11 @@ use anyhow::{Context, Result}; use clap::error::ErrorKind; use clap::{ArgMatches, Command}; use cli::{ - bool_false_id, bool_true_id, bootstrap_args, build_tools_command, parameter_id, - ToolCommandConfig, APPKIT_COMMAND_ABOUT, APPKIT_COMMAND_NAME, BL_TOOLS_BIN_NAME, - EXTENSION_DESCRIBE_COMMAND_ABOUT, EXTENSION_DESCRIBE_COMMAND_NAME, ROOT_BIN_NAME, - ROOT_COMMAND_NAME, ROOT_SUMMARY, TOOLS_COMMAND_NAME, + bool_false_id, bool_true_id, bootstrap_args, bootstrap_args_with_default_base_url, + build_tools_command, parameter_id, ToolCommandConfig, APPKIT_COMMAND_ABOUT, + APPKIT_COMMAND_NAME, BL_TOOLS_BIN_NAME, EXTENSION_DESCRIBE_COMMAND_ABOUT, + EXTENSION_DESCRIBE_COMMAND_NAME, ROOT_BIN_NAME, ROOT_COMMAND_NAME, ROOT_SUMMARY, + TOOLS_COMMAND_NAME, }; use runtime::{ load_extension, load_extensions, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, @@ -40,6 +41,7 @@ use crate::proto::squareup::cash::kgoose::api::v3::user_content; const BL_COMMAND_NAME: &str = "bl"; const BL_SUMMARY: &str = "BuilderLab command line tools"; +const BL_TOOLS_BASE_URL_ENV_VAR: &str = "BL_TOOLS_BASE_URL"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ToolsCliConfig { @@ -203,7 +205,16 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re return appkit::run(raw_args); } - let bootstrap = bootstrap_args(raw_args.iter().cloned())?; + let proxy_base_url = if config.builderlab_mode { + blox_tools_base_url()? + } else { + None + }; + let bootstrap = if let Some(base_url) = proxy_base_url.as_deref() { + bootstrap_args_with_default_base_url(raw_args.iter().cloned(), Some(base_url))? + } else { + bootstrap_args(raw_args.iter().cloned())? + }; let mut kgoose_config = KgooseConfig { base_url: bootstrap.base_url.clone(), @@ -212,6 +223,7 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re goosemcp_playpen: bootstrap.goosemcp_playpen.clone(), timeout_secs: bootstrap.timeout_secs, session_credential: None, + proxy_auth: false, }; let client = HttpKgooseClient; @@ -346,6 +358,14 @@ fn apply_builderlab_mode_if_needed(config: &mut KgooseConfig, builderlab_mode: b if !builderlab_mode { return Ok(()); } + if tools_proxy_auth_for_url(&config.base_url) { + config.proxy_auth = true; + config.session_credential = None; + if config.service_path == DEFAULT_KGOOSE_SERVICE_PATH { + config.service_path = normalize_kgoose_service_path("api")?; + } + return Ok(()); + } let bl_home = read_optional_env(BL_HOME_ENV_VAR)? .map(std::path::PathBuf::from) .unwrap_or_else(default_bl_home); @@ -364,6 +384,30 @@ fn apply_builderlab_mode_if_needed(config: &mut KgooseConfig, builderlab_mode: b Ok(()) } +fn blox_tools_base_url() -> Result> { + if std::env::var("IS_BLOX").as_deref() != Ok("true") + || !std::env::var("BLOX_WORKSTATION_ID").is_ok_and(|id| !id.is_empty()) + { + return Ok(None); + } + read_optional_env(BL_TOOLS_BASE_URL_ENV_VAR) +} + +fn tools_proxy_auth_for_url(base_url: &str) -> bool { + let Some(configured) = blox_tools_base_url().ok().flatten() else { + return false; + }; + let (Ok(configured), Ok(requested)) = (url::Url::parse(&configured), url::Url::parse(base_url)) + else { + return false; + }; + [&configured, &requested].iter().all(|url| { + matches!(url.scheme(), "http" | "https") + && url.username().is_empty() + && url.password().is_none() + }) && configured.origin() == requested.origin() +} + fn bl_org_required_error() -> anyhow::Error { bl::skills_api::failure( bl::skills_api::exit_codes::AUTH_REQUIRED, @@ -971,6 +1015,7 @@ mod tests { goosemcp_playpen: None, timeout_secs: 600.0, session_credential: None, + proxy_auth: false, } } diff --git a/src/runtime.rs b/src/runtime.rs index 10f6d1f..ef6f320 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -647,6 +647,7 @@ mod tests { goosemcp_playpen: None, timeout_secs: 600.0, session_credential: None, + proxy_auth: false, } } diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs index 84eee21..a05f503 100644 --- a/tests/bl_e2e.rs +++ b/tests/bl_e2e.rs @@ -4139,6 +4139,261 @@ fn bl_apps_pipeline_without_a_session_never_starts_browser_login() { // --------------------------------------------------------------------------- // bl tools passthrough +fn blox_tools_command(bl_home: &Path, base_url: &str) -> std::process::Command { + let mut command = bl_command(); + command + .env("BL_HOME", bl_home) + .env("IS_BLOX", "true") + .env("BLOX_WORKSTATION_ID", "tools-test-workstation") + .env("BL_TOOLS_BASE_URL", base_url) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", bl_home.join("sessions.json")); + command +} + +#[test] +fn bl_tools_blox_discovers_and_calls_tools_without_local_configuration_or_credentials() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"sum\":5}"}}], + "is_error": false + })), + ]); + let temp = temp_test_dir("bl-tools-blox-proxy"); + fs::write(temp.join("sessions.json"), "not json").expect("write unreadable session storage"); + let output = blox_tools_command(&temp, &server.base_url) + .env("STS_ACCESS_TOKEN", "ambient-sts-token") + .args([ + "tools", + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run proxy-authenticated tool"); + let (stdout, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("sum"), "stdout was: {stdout}"); + assert!(!temp.join("config.yaml").exists()); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); + assert_eq!(requests[1].path, BL_TOOLS_CALL_TOOL_PATH); + for request in &requests { + for header in [ + "authorization", + "cookie", + "x-bb-session-credential", + "x-forwarded-identity-token", + ] { + assert!(!request.headers.contains_key(header), "unexpected {header}"); + } + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_lists_extensions_without_local_configuration() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "extensions": [{"name": "utils", "description": "Utility helpers"}] + }))]); + let temp = temp_test_dir("bl-tools-blox-extensions"); + let output = blox_tools_command(&temp, &server.base_url) + .args(["tools", "--write-extensions"]) + .arg(temp.join("extensions.yaml")) + .output() + .expect("discover proxy-authenticated extensions"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/v3/list-extensions"); + assert!(fs::read_to_string(temp.join("extensions.yaml")) + .unwrap() + .contains("utils")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_preserves_same_origin_url_and_explicit_service_path() { + let server = MockServer::start(vec![list_tools_response( + "utils", + calculate_tool_schema(false), + )]); + let temp = temp_test_dir("bl-tools-blox-service-path"); + let output = blox_tools_command(&temp, &server.base_url) + .args([ + "tools", + "--base-url", + &format!("{}/proxy", server.base_url), + "--kgoose-service-path", + "/custom/api", + "utils", + "--help", + ]) + .output() + .expect("discover tools through explicit proxy path"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/proxy/custom/api/v3/list-tools"); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_proxy_auth_requires_both_workstation_markers() { + let temp = temp_test_dir("bl-tools-blox-markers"); + for (is_blox, workstation_id) in [("false", Some("id")), ("true", None), ("true", Some(""))] { + let server = MockServer::start(vec![]); + let mut command = blox_tools_command(&temp, &server.base_url); + command.env("IS_BLOX", is_blox); + if let Some(id) = workstation_id { + command.env("BLOX_WORKSTATION_ID", id); + } else { + command.env_remove("BLOX_WORKSTATION_ID"); + } + let output = command + .args(["tools", "--base-url", &server.base_url, "utils", "--help"]) + .output() + .expect("run without valid workstation markers"); + let (_, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(3), "stderr was: {stderr}"); + assert!(stderr.contains("org is not configured")); + assert!(server.finish().is_empty()); + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_proxy_auth_does_not_apply_outside_configured_origin() { + let temp = temp_test_dir("bl-tools-blox-origin"); + let server = MockServer::start(vec![]); + for base_url in [ + "http://other.example.test".to_string(), + server.base_url.replacen("http:", "https:", 1), + "http://127.0.0.1:1".to_string(), + server.base_url.replacen("http://", "http://user@", 1), + ] { + let output = blox_tools_command(&temp, &server.base_url) + .args(["tools", "--base-url", &base_url, "utils", "--help"]) + .output() + .expect("run explicit endpoint override"); + let (_, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(3), "stderr was: {stderr}"); + assert!(stderr.contains("org is not configured")); + } + let output = blox_tools_command(&temp, &server.base_url) + .env("KGOOSE_BASE_URL", "http://other.example.test") + .args(["tools", "utils", "--help"]) + .output() + .expect("run environment endpoint override"); + assert_eq!(output.status.code(), Some(3)); + assert!(server.finish().is_empty()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_proxy_auth_requires_a_valid_configured_endpoint() { + let temp = temp_test_dir("bl-tools-blox-endpoint"); + let server = MockServer::start(vec![]); + let userinfo_url = server.base_url.replacen("http://", "http://user@", 1); + for configured in [ + None, + Some("not a URL"), + Some("file:///tmp/tools"), + Some(userinfo_url.as_str()), + ] { + let mut command = blox_tools_command(&temp, &server.base_url); + if let Some(base_url) = configured { + command.env("BL_TOOLS_BASE_URL", base_url); + } else { + command.env_remove("BL_TOOLS_BASE_URL"); + } + let output = command + .args(["tools", "--base-url", &server.base_url, "utils", "--help"]) + .output() + .expect("run without a trusted endpoint"); + let (_, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(3), "stderr was: {stderr}"); + assert!(stderr.contains("org is not configured")); + } + assert!(server.finish().is_empty()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_proxy_auth_does_not_follow_redirects() { + let target = MockServer::start(vec![]); + let server = MockServer::start(vec![MockResponse::bytes( + 302, + vec![], + &[("Location", target.base_url.clone())], + )]); + let temp = temp_test_dir("bl-tools-blox-redirect"); + let output = blox_tools_command(&temp, &server.base_url) + .args(["tools", "linear", "--help"]) + .output() + .expect("run redirected tools request"); + let (_, stderr) = output_text(&output); + assert!(!output.status.success()); + assert!(stderr.contains("302"), "stderr was: {stderr}"); + assert_eq!(server.finish().len(), 1); + assert!(target.finish().is_empty()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_blox_other_origins_keep_local_session_authentication() { + assert_bl_tools_session_credential( + "bl-tools-blox-local-session", + "default", + "stored-local-session", + |_, command| { + command + .env("IS_BLOX", "true") + .env("BLOX_WORKSTATION_ID", "tools-test-workstation") + .env("BL_TOOLS_BASE_URL", "http://other.example.test"); + }, + ); +} + +#[test] +fn agent_tools_keeps_internal_identity_authentication_on_blox() { + let server = MockServer::start(vec![list_tools_response( + "utils", + calculate_tool_schema(false), + )]); + let output = server + .command() + .env("IS_BLOX", "true") + .env("BLOX_WORKSTATION_ID", "tools-test-workstation") + .env("BL_TOOLS_BASE_URL", &server.base_url) + .env("STS_ACCESS_TOKEN", "internal-sts-token") + .args(["utils", "--help"]) + .output() + .expect("run internal agent-tools"); + let (_, stderr) = output_text(&output); + assert!(output.status.success(), "stderr was: {stderr}"); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0] + .headers + .get("x-forwarded-identity-token") + .map(String::as_str), + Some("internal-sts-token") + ); +} + #[test] fn bl_tools_help_surfaces_schema_derived_flags() { let server = MockServer::start(vec![list_tools_response( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3cc4d11..a22980a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -141,6 +141,7 @@ impl MockServer { .env_remove("KGOOSE_SERVICE_PATH") .env_remove("KGOOSE_TIMEOUT") .env_remove("STS_ACCESS_TOKEN") + .env_remove("BL_TOOLS_BASE_URL") .env_remove("BL_HOME") .env_remove("BL_SKILLS_PROFILE") .env_remove("BL_AUTH_STORAGE") @@ -160,6 +161,7 @@ impl MockServer { .env_remove("KGOOSE_SERVICE_PATH") .env_remove("KGOOSE_TIMEOUT") .env_remove("STS_ACCESS_TOKEN") + .env_remove("BL_TOOLS_BASE_URL") .env("BL_HOME", bl_home) .env_remove("BL_SKILLS_CONFIG") .env_remove("BL_SKILLS_PROFILE") @@ -202,7 +204,8 @@ pub fn bl_command() -> Command { .env_remove("KGOOSE_PLAYPEN") .env_remove("KGOOSE_SERVICE_PATH") .env_remove("KGOOSE_TIMEOUT") - .env_remove("STS_ACCESS_TOKEN"); + .env_remove("STS_ACCESS_TOKEN") + .env_remove("BL_TOOLS_BASE_URL"); command } From 4b8817e6282080b30473d48c4477c69b3f9f237a Mon Sep 17 00:00:00 2001 From: Torsten Rudolf Date: Mon, 12 Oct 2026 16:28:41 +1100 Subject: [PATCH 2/2] fix: use direct mesh routes for blox tools Keep the backend service prefix in proxy mode because the public API rewrite is absent on direct mesh requests. Co-authored-by: Steve --- src/lib.rs | 3 --- tests/bl_e2e.rs | 9 +++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c6b493f..ba17c70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -361,9 +361,6 @@ fn apply_builderlab_mode_if_needed(config: &mut KgooseConfig, builderlab_mode: b if tools_proxy_auth_for_url(&config.base_url) { config.proxy_auth = true; config.session_credential = None; - if config.service_path == DEFAULT_KGOOSE_SERVICE_PATH { - config.service_path = normalize_kgoose_service_path("api")?; - } return Ok(()); } let bl_home = read_optional_env(BL_HOME_ENV_VAR)? diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs index a05f503..516b250 100644 --- a/tests/bl_e2e.rs +++ b/tests/bl_e2e.rs @@ -17,7 +17,8 @@ use zip::write::SimpleFileOptions; use common::{ bl_command, calculate_tool_schema, list_tools_response, output_text, temp_test_dir, write_bl_org_config, write_extensions_catalog, MockResponse, MockServer, - BL_TOOLS_CALL_TOOL_PATH, BL_TOOLS_LIST_TOOLS_PATH, + BL_TOOLS_CALL_TOOL_PATH, BL_TOOLS_LIST_TOOLS_PATH, CALL_TOOL_PATH, LIST_EXTENSIONS_PATH, + LIST_TOOLS_PATH, }; // --------------------------------------------------------------------------- @@ -4183,8 +4184,8 @@ fn bl_tools_blox_discovers_and_calls_tools_without_local_configuration_or_creden assert!(stdout.contains("sum"), "stdout was: {stdout}"); assert!(!temp.join("config.yaml").exists()); assert_eq!(requests.len(), 2); - assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); - assert_eq!(requests[1].path, BL_TOOLS_CALL_TOOL_PATH); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); + assert_eq!(requests[1].path, CALL_TOOL_PATH); for request in &requests { for header in [ "authorization", @@ -4214,7 +4215,7 @@ fn bl_tools_blox_lists_extensions_without_local_configuration() { assert!(output.status.success(), "stderr was: {stderr}"); assert_eq!(requests.len(), 1); - assert_eq!(requests[0].path, "/api/v3/list-extensions"); + assert_eq!(requests[0].path, LIST_EXTENSIONS_PATH); assert!(fs::read_to_string(temp.join("extensions.yaml")) .unwrap() .contains("utils"));