diff --git a/README.md b/README.md index 9fca77a..7851271 100644 --- a/README.md +++ b/README.md @@ -236,3 +236,13 @@ The backend `X-BB-Session-Credential` header, `BBIdentity` authorization scheme, Playpen routing key are protocol identifiers and retain their existing names. On Linux and Windows, keyring login storage is not implemented; the explicit `BL_AUTH_STORAGE=file` option is available as described in the local auth guide. + +On managed bb-public Blox workstations, `bl workspace list` uses the same +operator-configured Kgoose endpoint and Roxy owner identity as `bl tools`. +It needs no local org setting or `bl auth login`, and does not read or write +local session credentials. The server derives the org from the verified owner +and returns that owner's current workspace memberships. This requires the +shared workspace-list endpoint to accept workstation authentication, with the +matching Roxy policy deployed. +`bl workspace switch` continues to require a local CLI login; listing does not +change the workstation's active workspace or rotate a session. diff --git a/crates/builderlab-auth/src/workspace.rs b/crates/builderlab-auth/src/workspace.rs index 30c7a2b..2781991 100644 --- a/crates/builderlab-auth/src/workspace.rs +++ b/crates/builderlab-auth/src/workspace.rs @@ -76,6 +76,22 @@ pub fn list_workspaces( ) } +/// Uses the workstation proxy's owner credential without exposing a local session. +pub fn list_workstation_workspaces( + client: &Client, + playpen: Option<&str>, + server_url: &str, +) -> Result { + post_json( + client, + playpen, + server_url, + LIST_WORKSPACES_PATH, + None, + &ListWorkspacesRequest {}, + ) +} + pub fn switch_workspace( client: &Client, playpen: Option<&str>, @@ -110,13 +126,37 @@ where let session_credential = credential .session_credential_header_value() .ok_or_else(|| anyhow!("stored BuilderLab CLI auth session is empty"))?; + post_json( + client, + playpen, + server_url, + path, + Some(&session_credential), + body, + ) +} + +fn post_json( + client: &Client, + playpen: Option<&str>, + server_url: &str, + path: &'static str, + session_credential: Option<&str>, + body: &B, +) -> Result +where + T: for<'de> Deserialize<'de>, + B: Serialize + ?Sized, +{ let url = auth_url(server_url, path)?; let mut request = client .post(url) .header(USER_AGENT, CLI_USER_AGENT) .header(ACCEPT, "application/json") - .header(SESSION_CREDENTIAL_HEADER, session_credential) .json(body); + if let Some(credential) = session_credential { + request = request.header(SESSION_CREDENTIAL_HEADER, credential); + } if let Some(baggage) = playpen_baggage(playpen) { request = request.header("Baggage", baggage); } diff --git a/src/bl/runner.rs b/src/bl/runner.rs index 457b485..dde9e1b 100644 --- a/src/bl/runner.rs +++ b/src/bl/runner.rs @@ -40,9 +40,13 @@ fn run_resolved( matches: &ArgMatches, dispatch: fn(&SkillsConfig, &ArgMatches) -> Result<()>, ) -> Result<()> { - match dispatch(config, matches) { + run_with_output(config.json, || dispatch(config, matches)) +} + +pub fn run_with_output(json: bool, dispatch: impl FnOnce() -> Result<()>) -> Result<()> { + match dispatch() { Ok(()) => Ok(()), - Err(error) if config.json => { + Err(error) if json => { let (exit_code, payload) = failure_info(&error); eprintln!("{payload}"); Err(anyhow::Error::new(SilentJsonExit(exit_code))) diff --git a/src/bl/workspace.rs b/src/bl/workspace.rs index edf664d..cef4671 100644 --- a/src/bl/workspace.rs +++ b/src/bl/workspace.rs @@ -6,7 +6,8 @@ use anyhow::{anyhow, Context, Result}; use builderlab_auth::auth_login::build_auth_http_client; use builderlab_auth::auth_storage::{SessionCredentialStorage, StoredSessionCredential}; use builderlab_auth::workspace::{ - list_workspaces, switch_workspace, ListWorkspacesResponse, Workspace, WorkspaceHttpError, + list_workspaces, list_workstation_workspaces, switch_workspace, ListWorkspacesResponse, + Workspace, WorkspaceHttpError, }; use clap::{Arg, ArgAction, ArgMatches, Command}; use reqwest::header::HeaderValue; @@ -15,10 +16,14 @@ use serde_json::json; use super::auth_storage::{ default_session_storage, session_storage_key_from_config, SessionStorageKey, }; -use super::display::{print_json, stdin_is_tty, terminal_safe_text}; +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::{kgoose_service_url, SkillsConfig}; +use super::skills_config::{ + kgoose_service_url, normalize_kgoose_service_path, read_optional_env, SkillsConfig, + BL_KGOOSE_PLAYPEN_ENV_VAR, DEFAULT_KGOOSE_SERVICE_PATH, KGOOSE_PLAYPEN_ENV_VAR, + KGOOSE_SERVICE_PATH_ENV_VAR, +}; pub fn command() -> Command { Command::new("workspace") @@ -45,6 +50,13 @@ pub fn command() -> Command { } pub fn run(matches: &ArgMatches) -> Result<()> { + if matches.subcommand_name() == Some("list") { + if let Some(base_url) = workstation_base_url()? { + return runner::run_with_output(matches.get_flag("json"), || { + run_workstation_list(matches, &base_url) + }); + } + } runner::run(matches, dispatch) } @@ -62,17 +74,58 @@ pub fn describe_commands() -> serde_json::Value { fn run_list(config: &SkillsConfig) -> Result<()> { runner::ensure_org_configured(config)?; - let session = WorkspaceSession::load(config)?; - let response = session.list(config).map_err(map_workspace_request_error)?; + let response = WorkspaceSession::load(config)? + .list(config) + .map_err(|error| map_workspace_request_error(error, false))?; if config.json { return print_json(&response); } - print_workspace_list(config, &response); + print_workspace_list(&config.style, &response); + Ok(()) +} + +fn run_workstation_list(matches: &ArgMatches, base_url: &str) -> Result<()> { + let service_path = matches + .get_one::("kgoose-service-path") + .cloned() + .or(read_optional_env(KGOOSE_SERVICE_PATH_ENV_VAR)?) + .map(|value| normalize_kgoose_service_path(&value)) + .transpose()? + .unwrap_or_else(|| DEFAULT_KGOOSE_SERVICE_PATH.to_string()); + let playpen = read_optional_env(BL_KGOOSE_PLAYPEN_ENV_VAR)? + .or(read_optional_env(KGOOSE_PLAYPEN_ENV_VAR)?) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let client = build_auth_http_client(Duration::from_secs(30))?; + let response = list_workstation_workspaces( + &client, + playpen.as_deref(), + &kgoose_service_url(base_url, &service_path), + ) + .map_err(|error| map_workspace_request_error(error, true))?; + let json = matches.get_flag("json"); + if json { + return print_json(&response); + } + let style = Style::new( + matches.get_flag("no-color"), + json, + matches.get_flag("verbose"), + ); + print_workspace_list(&style, &response); Ok(()) } +fn workstation_base_url() -> Result> { + let Some(default_base_url) = crate::blox_tools_base_url()? else { + return Ok(None); + }; + let base_url = read_optional_env("KGOOSE_BASE_URL")?.unwrap_or(default_base_url); + Ok(crate::tools_proxy_auth_for_url(&base_url).then_some(base_url)) +} + fn run_switch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { runner::ensure_org_configured(config)?; let session = WorkspaceSession::load(config)?; @@ -97,14 +150,16 @@ fn run_switch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { )) } None => { - let response = session.list(config).map_err(map_workspace_request_error)?; + let response = session + .list(config) + .map_err(|error| map_workspace_request_error(error, false))?; prompt_for_workspace(config, &response)? } }; let response = session .switch(config, &workspace_identifier) - .map_err(map_workspace_request_error)?; + .map_err(|error| map_workspace_request_error(error, false))?; let switched = if let Some(new_credential) = response.session_credential { if new_credential.trim().is_empty() { anyhow::bail!( @@ -221,11 +276,16 @@ fn auth_required_error() -> anyhow::Error { ) } -fn map_workspace_request_error(error: anyhow::Error) -> anyhow::Error { +fn map_workspace_request_error(error: anyhow::Error, workstation: bool) -> anyhow::Error { let Some(http_error) = error.downcast_ref::() else { return error; }; match http_error.status { + 401 if workstation => failure( + exit_codes::AUTH_REQUIRED, + "workstation_auth_required", + "Workstation authentication failed; ask the platform operator to check the workstation's credentials", + ), 401 => auth_required_error(), 403 => failure( exit_codes::FORBIDDEN, @@ -236,7 +296,7 @@ fn map_workspace_request_error(error: anyhow::Error) -> anyhow::Error { } } -fn print_workspace_list(config: &SkillsConfig, response: &ListWorkspacesResponse) { +fn print_workspace_list(style: &Style, response: &ListWorkspacesResponse) { if response.workspaces.is_empty() { println!("No workspaces found."); return; @@ -244,7 +304,7 @@ fn print_workspace_list(config: &SkillsConfig, response: &ListWorkspacesResponse for workspace in &response.workspaces { let active = workspace.workspace_identifier.as_deref() == response.active_workspace_identifier.as_deref(); - println!("{}", workspace_line(config, workspace, active)); + println!("{}", workspace_line(style, workspace, active)); } } @@ -262,7 +322,7 @@ fn prompt_for_workspace( eprintln!( " {}) {}", index + 1, - workspace_line(config, workspace, active) + workspace_line(&config.style, workspace, active) ); } eprint!("Selection: "); @@ -290,7 +350,7 @@ fn select_workspace(workspaces: &[Workspace], answer: &str) -> Result { .context("Selected workspace has no identifier") } -fn workspace_line(config: &SkillsConfig, workspace: &Workspace, active: bool) -> String { +fn workspace_line(style: &Style, workspace: &Workspace, active: bool) -> String { let display_name = workspace .display_name .as_deref() @@ -303,11 +363,10 @@ fn workspace_line(config: &SkillsConfig, workspace: &Workspace, active: bool) -> let mut line = format!( "{} {}", terminal_safe_text(display_name), - config.style.dim(&terminal_safe_text(identifier)) + style.dim(&terminal_safe_text(identifier)) ); if active { - write!(line, " {}", config.style.green("(active)")) - .expect("writing to a String cannot fail"); + write!(line, " {}", style.green("(active)")).expect("writing to a String cannot fail"); } line } diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs index dee6d11..f5bebd2 100644 --- a/tests/bl_e2e.rs +++ b/tests/bl_e2e.rs @@ -4154,6 +4154,234 @@ fn blox_tools_command(bl_home: &Path, base_url: &str) -> std::process::Command { command } +#[test] +fn bl_workspace_blox_lists_without_org_or_local_credentials() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspaces": [{"workspace_identifier": "workspace-team", "display_name": "Team", "roles": ["ROLE_USER"]}], + "active_workspace_identifier": "workspace-team" + }))]); + let temp = temp_test_dir("bl-workspace-blox-list"); + fs::write(temp.join("sessions.json"), "not json").unwrap(); + let output = blox_tools_command(&temp, &server.base_url) + .env("STS_ACCESS_TOKEN", "ambient-token") + .args(["workspace", "list", "--json"]) + .output() + .unwrap(); + let (stdout, stderr) = output_text(&output); + assert!(output.status.success(), "stderr was: {stderr}"); + let result: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + result["workspaces"][0]["workspace_identifier"], + "workspace-team" + ); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/cash-app/goose/v1/workspaces/list"); + for header in [ + "authorization", + "cookie", + "x-bb-session-credential", + "x-forwarded-identity-token", + ] { + assert!( + !requests[0].headers.contains_key(header), + "unexpected {header}" + ); + } + assert_eq!( + fs::read_to_string(temp.join("sessions.json")).unwrap(), + "not json" + ); + assert!(!temp.join("config.yaml").exists()); + fs::remove_dir_all(temp).unwrap(); +} + +#[test] +fn bl_workspace_blox_routes_same_origin_overrides_with_tools_service_path() { + for (env_path, cli_path, expected_path) in [ + (None, None, "/cash-app/goose"), + (Some("/env/goose/"), None, "/env/goose"), + (None, Some("/cli/goose/"), "/cli/goose"), + (Some("/env/goose/"), Some("/cli/goose/"), "/cli/goose"), + ] { + let server = MockServer::start(vec![MockResponse::json(json!({"workspaces": []}))]); + let temp = temp_test_dir("bl-workspace-blox-service-path"); + let mut command = blox_tools_command(&temp, &server.base_url); + command + .env("KGOOSE_BASE_URL", format!("{}/proxy", server.base_url)) + .args(["workspace", "list", "--json"]); + if let Some(path) = env_path { + command.env("KGOOSE_SERVICE_PATH", path); + } + if let Some(path) = cli_path { + command.args(["--kgoose-service-path", path]); + } + let output = command.output().unwrap(); + 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].path, + format!("/proxy{expected_path}/v1/workspaces/list") + ); + fs::remove_dir_all(temp).unwrap(); + } +} + +#[test] +fn bl_workspace_blox_ignores_invalid_local_configuration() { + for filename in ["skills.yaml", "config.yaml"] { + for unreadable in [false, true] { + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspaces": [{"workspace_identifier": "workspace-team", "display_name": "Team", "roles": ["ROLE_USER"]}], + "active_workspace_identifier": "workspace-team" + }))]); + let temp = temp_test_dir("bl-workspace-blox-invalid-config"); + let config_path = temp.join(filename); + if unreadable { + fs::create_dir(&config_path).unwrap(); + } else { + fs::write(&config_path, "invalid: [").unwrap(); + } + let output = blox_tools_command(&temp, &server.base_url) + .args(["workspace", "list", "--json"]) + .output() + .unwrap(); + let (stdout, stderr) = output_text(&output); + assert!( + output.status.success(), + "{filename}, unreadable={unreadable}: {stderr}" + ); + let result: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + result["workspaces"][0]["workspace_identifier"], + "workspace-team" + ); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/cash-app/goose/v1/workspaces/list"); + if unreadable { + assert!(config_path.is_dir()); + } else { + assert_eq!(fs::read_to_string(&config_path).unwrap(), "invalid: ["); + } + assert!(!temp.join("sessions.json").exists()); + fs::remove_dir_all(temp).unwrap(); + } + } +} + +#[test] +fn bl_workspace_blox_does_not_proxy_switch_or_unmanaged_requests() { + let temp = temp_test_dir("bl-workspace-blox-boundaries"); + let server = MockServer::start(vec![]); + for args in [ + vec!["workspace", "list"], + vec!["workspace", "switch", "--workspace", "workspace-team"], + ] { + let output = blox_tools_command(&temp, &server.base_url) + .env("IS_BLOX", "false") + .args(args) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3), "{:?}", output_text(&output)); + } + let output = blox_tools_command(&temp, &server.base_url) + .args(["workspace", "switch", "--workspace", "workspace-team"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3)); + let output = blox_tools_command(&temp, &server.base_url) + .env("KGOOSE_BASE_URL", "http://other.example.test") + .args(["workspace", "list"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3)); + assert!(server.finish().is_empty()); + fs::remove_dir_all(temp).unwrap(); +} + +#[test] +fn bl_workspace_session_auth_failure_requests_a_local_login() { + for args in [ + vec!["workspace", "list", "--json"], + vec![ + "workspace", + "switch", + "--workspace", + "workspace-team", + "--json", + ], + ] { + let temp = temp_test_dir("bl-workspace-session-auth-failure"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::text(401, "Unauthorized")]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(args) + .output() + .unwrap(); + let (_, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(3)); + assert!(stderr.contains("bl auth login"), "stderr was: {stderr}"); + assert!(!stderr.contains("workstation_auth_required")); + assert_eq!(server.finish().len(), 1); + fs::remove_dir_all(temp).unwrap(); + } +} + +#[test] +fn bl_workspace_blox_auth_failure_does_not_request_a_local_login() { + let server = MockServer::start(vec![MockResponse::text(401, "Unauthorized")]); + let temp = temp_test_dir("bl-workspace-blox-auth-failure"); + let output = blox_tools_command(&temp, &server.base_url) + .args(["workspace", "list", "--json"]) + .output() + .unwrap(); + let (_, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(3)); + assert!( + stderr.contains("workstation_auth_required"), + "stderr was: {stderr}" + ); + assert!(!stderr.contains("bl auth login")); + assert!(!temp.join("sessions.json").exists()); + assert!(!temp.join("config.yaml").exists()); + assert_eq!(server.finish().len(), 1); + fs::remove_dir_all(temp).unwrap(); +} + +#[test] +fn bl_workspace_blox_does_not_follow_redirects() { + let destination = MockServer::start(vec![]); + let server = MockServer::start(vec![MockResponse { + status: 307, + headers: [("Location".to_string(), destination.base_url.clone())].into(), + body: Vec::new(), + }]); + let temp = temp_test_dir("bl-workspace-blox-redirect"); + let output = blox_tools_command(&temp, &server.base_url) + .args(["workspace", "list"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(server.finish().len(), 1); + assert!(destination.finish().is_empty()); + fs::remove_dir_all(temp).unwrap(); +} + #[test] fn bl_tools_blox_discovers_and_calls_tools_without_local_configuration_or_credentials() { let server = MockServer::start(vec![