Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 41 additions & 1 deletion crates/builderlab-auth/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListWorkspacesResponse> {
post_json(
client,
playpen,
server_url,
LIST_WORKSPACES_PATH,
None,
&ListWorkspacesRequest {},
)
}

pub fn switch_workspace(
client: &Client,
playpen: Option<&str>,
Expand Down Expand Up @@ -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<T, B>(
client: &Client,
playpen: Option<&str>,
server_url: &str,
path: &'static str,
session_credential: Option<&str>,
body: &B,
) -> Result<T>
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);
}
Expand Down
8 changes: 6 additions & 2 deletions src/bl/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
91 changes: 75 additions & 16 deletions src/bl/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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)
}

Expand All @@ -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::<String>("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<Option<String>> {
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)?;
Expand All @@ -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!(
Expand Down Expand Up @@ -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::<WorkspaceHttpError>() 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,
Expand All @@ -236,15 +296,15 @@ 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;
}
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));
}
}

Expand All @@ -262,7 +322,7 @@ fn prompt_for_workspace(
eprintln!(
" {}) {}",
index + 1,
workspace_line(config, workspace, active)
workspace_line(&config.style, workspace, active)
);
}
eprint!("Selection: ");
Expand Down Expand Up @@ -290,7 +350,7 @@ fn select_workspace(workspaces: &[Workspace], answer: &str) -> Result<String> {
.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()
Expand All @@ -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
}
Expand Down
Loading
Loading