From 4836e6c22452893ff1b3309d6234fa3c7b8e53e6 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 08:32:17 -0400 Subject: [PATCH 1/7] Remove retired workspace app sharing commands --- src/bl/apps.rs | 438 ++++--------------------------------------------- 1 file changed, 35 insertions(+), 403 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 96212d8..6672866 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -68,7 +68,7 @@ const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ ]; pub fn command() -> Command { - let about = "Manage apps through the BuilderLab Apps Platform."; + let about = "Manage apps through the BuilderLab Apps Platform. Cross-workspace sharing is no longer supported. Use `bl apps handoff` to inspect the audience and open Share & manage for community sharing."; Command::new("apps") .about("Manage apps through Apps Platform") .long_about(about) @@ -467,38 +467,6 @@ pub fn command() -> Command { ), )), ) - .subcommand( - Command::new("share") - .about("Share a restricted app with a BuilderLab workspace") - .long_about( - "Grant or revoke viewing access for current members of a non-personal BuilderLab workspace. \ - The app owner must belong to the target workspace to grant access, but can revoke after leaving it. \ - Use `bl apps access get` to see current grants.", - ) - .subcommand_required(true) - .arg_required_else_help(true) - .disable_help_subcommand(true) - .subcommand(share_action_command("grant")) - .subcommand(share_action_command("revoke")), - ) -} - -fn share_action_command(action: &'static str) -> Command { - control_plane_args( - Command::new(action) - .about(if action == "grant" { - "Let current members of a workspace view an app" - } else { - "Remove a workspace's viewing access to an app" - }) - .arg(Arg::new("app-id").value_name("APP_ID").required(true)) - .arg( - Arg::new("workspace-id") - .value_name("WORKSPACE_ID") - .required(true) - .help("BuilderLab workspace identifier"), - ), - ) } fn control_plane_args(command: Command) -> Command { @@ -550,7 +518,6 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("ready", ready_matches)) => run_ready(config, ready_matches), Some(("debug", debug_matches)) => run_debug(config, debug_matches), Some(("access", access_matches)) => run_access(config, access_matches), - Some(("share", share_matches)) => run_share(config, share_matches), _ => anyhow::bail!("expected an apps subcommand"), } } @@ -1149,44 +1116,6 @@ fn run_access(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { } } -fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let (action, action_matches) = matches - .subcommand() - .context("expected share grant or revoke")?; - let app_id = action_matches - .get_one::("app-id") - .context("expected app id")?; - let workspace_id = action_matches - .get_one::("workspace-id") - .context("expected workspace id")?; - let (client, credential) = control_plane_context(config, action_matches)?; - // Sharing credentials bind the owner, app and target independently of the - // ordinary credential's selected workspace. Read only the CAS inputs on - // that same route. Kgoose checks target eligibility, and Compose rechecks - // ownership, restricted visibility and revision when applying the grant. - let current = client.get_workspace_grant_revision(&credential, app_id, workspace_id)?; - let lifecycle_id = current - .get("lifecycle_id") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .context("app has no active sharing lifecycle") - .map_err(|error| correlate_response_error(error, ¤t))?; - let expected_revision = current - .get("workspace_grant_revision") - .and_then(Value::as_u64) - .context("app sharing response has no workspace grant revision") - .map_err(|error| correlate_response_error(error, ¤t))?; - let response = client.update_workspace_grant( - &credential, - app_id, - workspace_id, - lifecycle_id, - action, - expected_revision, - )?; - print_json(&response) -} - fn run_access_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let app_id = matches .get_one::("app-id") @@ -1826,58 +1755,6 @@ impl ControlPlaneClient { }) } - fn update_workspace_grant( - &self, - credential: &ComposeSessionCredential, - app_id: &str, - workspace_id: &str, - lifecycle_id: &str, - action: &str, - expected_revision: u64, - ) -> Result { - let url = self.workspace_grant_url(app_id, workspace_id)?; - let path = request_path(&url); - let method = match action { - "grant" => "POST", - "revoke" => "DELETE", - _ => anyhow::bail!("unsupported workspace sharing action"), - }; - let body = json!({ - "lifecycle_id": lifecycle_id, - "expected_revision": expected_revision, - }); - self.authorized_json_request(credential, method, &path, |authorization| { - let request = if method == "POST" { - self.client.post(url.clone()) - } else { - self.client.delete(url.clone()) - }; - self.standard_request(request, authorization) - .json(&body) - .build() - .context("build Apps Platform workspace sharing request") - }) - } - - fn get_workspace_grant_revision( - &self, - credential: &ComposeSessionCredential, - app_id: &str, - workspace_id: &str, - ) -> Result { - self.get_url(credential, self.workspace_grant_url(app_id, workspace_id)?) - } - - fn workspace_grant_url(&self, app_id: &str, workspace_id: &str) -> Result { - let mut url = self.app_url(app_id, &[])?; - url.path_segments_mut() - .map_err(|_| { - anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") - })? - .extend(["sharing", "workspaces", workspace_id]); - Ok(url) - } - fn get_app_resource( &self, credential: &ComposeSessionCredential, @@ -3559,174 +3436,6 @@ mod tests { ); } - #[test] - fn bl_apps_share_grant_uses_owner_scoped_read_without_source_workspace() { - for proxy in [false, true] { - let credential = "apps-e2e-only.share.session"; - let auth_server = ProcessServer::start(if proxy { - vec![] - } else { - vec![process_auth_response()] - }); - // The sharing read deliberately returns only CAS inputs, not the - // ordinary access policy from the owner's source workspace. - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({ - "ok": true, - "app_id": "my-app", - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - })), - ProcessResponse::json(json!({"ok": true, "workspace_grant_revision": 8})), - ]); - let mut command = process_command( - &auth_server, - &control_plane, - &["apps", "share", "grant", "my-app", "target-team", "--json"], - credential, - ); - command.env( - APPS_BASE_URL_ENV_VAR, - if proxy { - "http://compose.mesh.example" - } else { - APPROVED_TEST_BASE_URL - }, - ); - if proxy { - command.env("BL_APPS_E2E_PROXY", "true"); - } - let output = command.output().expect("run workspace grant process"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let auth_requests = auth_server.finish(); - assert_eq!(auth_requests.len(), usize::from(!proxy)); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 2); - let path = "/v1/agent/apps/my-app/sharing/workspaces/target-team"; - assert_eq!(requests[0].method, "GET"); - assert_eq!(requests[0].path, path); - assert_eq!(requests[1].method, "POST"); - assert_eq!(requests[1].path, path); - assert_eq!( - requests[1].body, - json!({"lifecycle_id": "life-123", "expected_revision": 7}) - ); - if proxy { - for request in &requests { - for header in ["authorization", "cookie", "x-bb-session-credential"] { - assert!(!request.headers.contains_key(header)); - } - } - } else { - assert_process_auth(&auth_requests[0], credential); - assert_process_control_plane(&requests[0], "GET", path, credential); - assert_process_control_plane(&requests[1], "POST", path, credential); - } - } - } - - #[test] - fn bl_apps_share_grant_preserves_server_denials_and_does_not_retry() { - for (read_denied, status, code) in [ - (true, 403, "workspace_grant_denied"), - (false, 403, "workspace_grant_denied"), - (false, 409, "workspace_grant_conflict"), - ] { - let auth_server = ProcessServer::start(vec![]); - let denied = ProcessResponse::raw(status, json!({"error": {"code": code}}).to_string()) - .with_trace_id(REQUEST_TRACE_ID); - let mut responses = vec![]; - if !read_denied { - responses.push(ProcessResponse::json(json!({ - "ok": true, - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - }))); - } - responses.push(denied); - let control_plane = ProcessServer::start(responses); - let output = process_command( - &auth_server, - &control_plane, - &["apps", "share", "grant", "my-app", "target-team", "--json"], - "apps-e2e-only.unused", - ) - .env("BL_APPS_E2E_PROXY", "true") - .env(APPS_BASE_URL_ENV_VAR, "http://compose.mesh.example") - .output() - .expect("run denied workspace grant process"); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains(code), "{stderr}"); - assert!(stderr.contains(REQUEST_TRACE_ID), "{stderr}"); - assert!(auth_server.finish().is_empty()); - let requests = control_plane.finish(); - assert_eq!(requests.len(), if read_denied { 1 } else { 2 }); - let path = "/v1/agent/apps/my-app/sharing/workspaces/target-team"; - assert_eq!(requests[0].method, "GET"); - assert_eq!(requests[0].path, path); - if !read_denied { - assert_eq!(requests[1].method, "POST"); - assert_eq!(requests[1].path, path); - } - } - } - - #[test] - fn bl_apps_share_revoke_reads_only_the_target_grant_before_delete() { - let credential = "apps-e2e-only.revoke.session+credential"; - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({ - "ok": true, - "app_id": "my-app", - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - })), - ProcessResponse::json(json!({"ok": true, "workspace_grant_revision": 8})), - ]); - let mut command = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "share", - "revoke", - "my-app", - "former-team", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", - ], - credential, - ); - let output = command - .output() - .expect("run workspace revoke process command"); - assert!( - output.status.success(), - "stderr was: {}", - String::from_utf8_lossy(&output.stderr) - ); - let auth_requests = auth_server.finish(); - assert_eq!(auth_requests.len(), 1); - assert_process_auth(&auth_requests[0], credential); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 2); - let path = "/v1/agent/apps/my-app/sharing/workspaces/former-team"; - assert_process_control_plane(&requests[0], "GET", path, credential); - assert_process_control_plane(&requests[1], "DELETE", path, credential); - assert_eq!( - requests[1].body, - json!({"lifecycle_id":"life-123","expected_revision":7}) - ); - } - #[test] fn bl_apps_access_process_explicitly_clears_restricted_viewers() { let credential = "apps-e2e-only.access.clear.session+credential"; @@ -6162,49 +5871,6 @@ mod tests { } } - #[test] - fn bl_apps_share_process_retains_trace_on_validation_failures() { - for body in [ - json!({}), - json!({"lifecycle_id":"life-123"}), - json!({"lifecycle_id":"life-123", "workspace_grant_revision":-1}), - ] { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(body).with_trace_id(REQUEST_TRACE_ID) - ]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "share", - "grant", - "app", - "test-workspace", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ], - "apps-e2e-only.trace-share.session", - ) - .output() - .expect("run invalid share command"); - assert_eq!( - process_error(&output)["error"]["trace_id"], - REQUEST_TRACE_ID - ); - auth_server.finish(); - assert_eq!( - control_plane.finish().len(), - 1, - "never mutate invalid sharing state" - ); - } - } - #[test] fn delete_retains_response_id_through_http_and_unknown_outcome_failures() { for (status, body, expected_code, expected_exit) in [ @@ -7451,75 +7117,41 @@ mod tests { } #[test] - fn workspace_grant_uses_target_path_and_lifecycle_revision() { - 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 || { - for method in ["GET", "POST", "DELETE"] { - let mut request = server.recv().expect("receive sharing request"); - assert_eq!(request.method().as_str(), method); - assert_eq!( - request.url(), - "/v1/agent/apps/my-app/sharing/workspaces/team-workspace" - ); - let mut body = String::new(); - request - .as_reader() - .read_to_string(&mut body) - .expect("read body"); - if method == "GET" { - assert!(body.is_empty()); - } else { - assert_eq!( - serde_json::from_str::(&body).expect("parse body"), - json!({"lifecycle_id":"life-123","expected_revision":if method == "POST" { 4 } else { 5 }}) - ); - } - request - .respond( - Response::from_string(if method == "GET" { - r#"{"ok":true,"lifecycle_id":"life-123","workspace_grant_revision":5}"# - } else { - r#"{"ok":true}"# - }) - .with_header( - Header::from_bytes("Content-Type", "application/json") - .expect("build content type"), - ), - ) - .expect("respond to sharing request"); - } - }); - let client = test_control_plane_client(&base_url, Duration::from_secs(2)); - let credential = test_credential("sharing_session_credential_123456789012345"); - let current = client - .get_workspace_grant_revision(&credential, "my-app", "team-workspace") - .expect("read sharing revision without deployment workspace access"); - assert_eq!(current["lifecycle_id"], "life-123"); - assert_eq!(current["workspace_grant_revision"], 5); - let response = client - .update_workspace_grant( - &credential, - "my-app", - "team-workspace", - "life-123", - "grant", - 4, - ) - .expect("update workspace grant"); - assert_eq!(response["ok"], true); - let response = client - .update_workspace_grant( - &credential, - "my-app", - "team-workspace", - "life-123", - "revoke", - 5, + fn workspace_sharing_is_absent_from_help_and_command_metadata() { + let mut apps = command(); + assert!(apps.find_subcommand("share").is_none()); + let help = apps.render_long_help().to_string(); + assert!(!help.contains("Share a restricted app with a BuilderLab workspace")); + let described = describe_commands(); + assert!(!described.to_string().contains("WORKSPACE_ID")); + } + + #[test] + fn retired_workspace_sharing_never_authenticates_or_reaches_compose() { + for action in ["grant", "revoke"] { + let auth_server = ProcessServer::start(vec![]); + let control_plane = ProcessServer::start(vec![]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "share", + action, + "my-app", + "target-workspace", + "--json", + ], + "apps-e2e-only.retired-share.session", ) - .expect("revoke workspace grant"); - assert_eq!(response["ok"], true); - server_thread.join().expect("join control-plane server"); + .output() + .expect("run retired sharing command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("share"), "{stderr}"); + assert!(auth_server.finish().is_empty()); + assert!(control_plane.finish().is_empty()); + } } #[test] From 3a4823a64d5398750d5718a68367ac2aaec4ee62 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 10:28:19 -0400 Subject: [PATCH 2/7] Return a retirement notice for legacy workspace sharing calls --- src/bl/apps.rs | 90 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 6672866..5ed77c3 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -40,6 +40,7 @@ const APPS_CLIENT_VERSION_ENV_VAR: &str = "BL_APPS_CLIENT_VERSION"; // Apps compatibility evolves independently of the standalone CLI release // numbering. Keep the beta package version in --version and User-Agent. const APPS_PROTOCOL_VERSION: &str = "0.2.0"; +const WORKSPACE_SHARING_RETIRED: &str = "Cross-workspace sharing was retired; use Share & manage."; #[cfg(test)] const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BL_APPS_E2E_CONTROL_PLANE_URL"; #[cfg(test)] @@ -467,6 +468,33 @@ pub fn command() -> Command { ), )), ) + .subcommand( + Command::new("share") + .hide(true) + .about(WORKSPACE_SHARING_RETIRED) + .disable_help_subcommand(true) + .subcommand(retired_workspace_share_command("grant")) + .subcommand(retired_workspace_share_command("revoke")), + ) +} + +fn retired_workspace_share_command(action: &'static str) -> Command { + Command::new(action) + .hide(true) + .about(WORKSPACE_SHARING_RETIRED) + .arg(Arg::new("app-id").value_name("APP_ID")) + .arg(Arg::new("workspace-id").value_name("WORKSPACE_ID")) + .arg( + Arg::new("apps-base-url") + .long("base-url") + .visible_alias("control-plane-url") + .value_name("URL"), + ) + .arg( + Arg::new("apps-client-version") + .long("client-version") + .value_name("VERSION"), + ) } fn control_plane_args(command: Command) -> Command { @@ -497,6 +525,16 @@ pub fn describe_commands() -> Value { } pub fn run(matches: &ArgMatches) -> Result<()> { + // Older skills can still call this path. Refuse before reading config or credentials. + if matches.subcommand_name() == Some("share") { + return runner::run_with_output(matches.get_flag("json"), || { + Err(failure( + exit_codes::GENERAL, + "workspace_sharing_retired", + WORKSPACE_SHARING_RETIRED, + )) + }); + } runner::run(matches, dispatch) } @@ -7119,36 +7157,58 @@ mod tests { #[test] fn workspace_sharing_is_absent_from_help_and_command_metadata() { let mut apps = command(); - assert!(apps.find_subcommand("share").is_none()); + assert!(apps.find_subcommand("share").unwrap().is_hide_set()); let help = apps.render_long_help().to_string(); assert!(!help.contains("Share a restricted app with a BuilderLab workspace")); + assert!(!help.contains("\n share")); let described = describe_commands(); assert!(!described.to_string().contains("WORKSPACE_ID")); + assert!(described["commands"] + .as_array() + .unwrap() + .iter() + .all(|command| command["name"] != "share")); } #[test] fn retired_workspace_sharing_never_authenticates_or_reaches_compose() { - for action in ["grant", "revoke"] { + for (action, proxy, json) in [ + ("grant", false, true), + ("revoke", false, false), + ("grant", true, false), + ("revoke", true, true), + ] { let auth_server = ProcessServer::start(vec![]); let control_plane = ProcessServer::start(vec![]); - let output = process_command( + let mut args = vec!["apps", "share", action, "my-app", "target-workspace"]; + if json { + args.extend([ + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]); + } + let mut command = process_command( &auth_server, &control_plane, - &[ - "apps", - "share", - action, - "my-app", - "target-workspace", - "--json", - ], + &args, "apps-e2e-only.retired-share.session", - ) - .output() - .expect("run retired sharing command"); + ); + if proxy { + command.env("BL_APPS_E2E_PROXY", "true"); + } + let output = command.output().expect("run retired sharing command"); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("share"), "{stderr}"); + assert!(stderr.contains(WORKSPACE_SHARING_RETIRED), "{stderr}"); + if json { + assert_eq!( + process_error(&output)["error"]["code"], + "workspace_sharing_retired" + ); + } assert!(auth_server.finish().is_empty()); assert!(control_plane.finish().is_empty()); } From b2ada83c01b8981885416a90deb28dd024b5c5d2 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 10:32:19 -0400 Subject: [PATCH 3/7] Omit retired app sharing stubs from shell completions --- src/bl/apps.rs | 29 ++++++++++++++++++++++++++--- src/lib.rs | 4 +++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 5ed77c3..4a7a35b 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -69,8 +69,16 @@ const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ ]; pub fn command() -> Command { + build_command(true) +} + +pub(crate) fn completion_command() -> Command { + build_command(false) +} + +fn build_command(include_retired_sharing: bool) -> Command { let about = "Manage apps through the BuilderLab Apps Platform. Cross-workspace sharing is no longer supported. Use `bl apps handoff` to inspect the audience and open Share & manage for community sharing."; - Command::new("apps") + let command = Command::new("apps") .about("Manage apps through Apps Platform") .long_about(about) .subcommand_required(true) @@ -467,8 +475,9 @@ pub fn command() -> Command { .help("Optional Compose environment override"), ), )), - ) - .subcommand( + ); + if include_retired_sharing { + command.subcommand( Command::new("share") .hide(true) .about(WORKSPACE_SHARING_RETIRED) @@ -476,6 +485,9 @@ pub fn command() -> Command { .subcommand(retired_workspace_share_command("grant")) .subcommand(retired_workspace_share_command("revoke")), ) + } else { + command + } } fn retired_workspace_share_command(action: &'static str) -> Command { @@ -7168,6 +7180,17 @@ mod tests { .unwrap() .iter() .all(|command| command["name"] != "share")); + let mut completion = completion_command(); + let mut script = Vec::new(); + clap_complete::generate( + clap_complete::Shell::Bash, + &mut completion, + "bl-apps", + &mut script, + ); + assert!(!String::from_utf8(script) + .unwrap() + .contains("__subcmd__share")); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 82620e7..3108d12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,7 +159,9 @@ fn run_bl_with_argv(argv: Vec) -> Result<()> { .get_one::("shell") .copied() .context("expected a shell")?; - let mut command = build_bl_command(); + // clap_complete includes hidden commands, so omit retired Apps stubs from its tree. + let mut command = + build_bl_command().mut_subcommand("apps", |_| bl::apps::completion_command()); clap_complete::generate(shell, &mut command, BL_COMMAND_NAME, &mut std::io::stdout()); Ok(()) } From 779fae69fccaea143520e361da18639c44637d30 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 11:00:39 -0400 Subject: [PATCH 4/7] Remove retired workspace sharing stubs --- src/bl/apps.rs | 117 +++++++------------------------------------------ src/lib.rs | 4 +- 2 files changed, 18 insertions(+), 103 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 4a7a35b..17d8cf7 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -40,7 +40,6 @@ const APPS_CLIENT_VERSION_ENV_VAR: &str = "BL_APPS_CLIENT_VERSION"; // Apps compatibility evolves independently of the standalone CLI release // numbering. Keep the beta package version in --version and User-Agent. const APPS_PROTOCOL_VERSION: &str = "0.2.0"; -const WORKSPACE_SHARING_RETIRED: &str = "Cross-workspace sharing was retired; use Share & manage."; #[cfg(test)] const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BL_APPS_E2E_CONTROL_PLANE_URL"; #[cfg(test)] @@ -69,16 +68,8 @@ const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ ]; pub fn command() -> Command { - build_command(true) -} - -pub(crate) fn completion_command() -> Command { - build_command(false) -} - -fn build_command(include_retired_sharing: bool) -> Command { - let about = "Manage apps through the BuilderLab Apps Platform. Cross-workspace sharing is no longer supported. Use `bl apps handoff` to inspect the audience and open Share & manage for community sharing."; - let command = Command::new("apps") + let about = "Manage apps through the BuilderLab Apps Platform. Use `bl apps handoff` to inspect the audience and open Share & manage for community sharing."; + Command::new("apps") .about("Manage apps through Apps Platform") .long_about(about) .subcommand_required(true) @@ -475,37 +466,6 @@ fn build_command(include_retired_sharing: bool) -> Command { .help("Optional Compose environment override"), ), )), - ); - if include_retired_sharing { - command.subcommand( - Command::new("share") - .hide(true) - .about(WORKSPACE_SHARING_RETIRED) - .disable_help_subcommand(true) - .subcommand(retired_workspace_share_command("grant")) - .subcommand(retired_workspace_share_command("revoke")), - ) - } else { - command - } -} - -fn retired_workspace_share_command(action: &'static str) -> Command { - Command::new(action) - .hide(true) - .about(WORKSPACE_SHARING_RETIRED) - .arg(Arg::new("app-id").value_name("APP_ID")) - .arg(Arg::new("workspace-id").value_name("WORKSPACE_ID")) - .arg( - Arg::new("apps-base-url") - .long("base-url") - .visible_alias("control-plane-url") - .value_name("URL"), - ) - .arg( - Arg::new("apps-client-version") - .long("client-version") - .value_name("VERSION"), ) } @@ -537,16 +497,6 @@ pub fn describe_commands() -> Value { } pub fn run(matches: &ArgMatches) -> Result<()> { - // Older skills can still call this path. Refuse before reading config or credentials. - if matches.subcommand_name() == Some("share") { - return runner::run_with_output(matches.get_flag("json"), || { - Err(failure( - exit_codes::GENERAL, - "workspace_sharing_retired", - WORKSPACE_SHARING_RETIRED, - )) - }); - } runner::run(matches, dispatch) } @@ -7169,69 +7119,36 @@ mod tests { #[test] fn workspace_sharing_is_absent_from_help_and_command_metadata() { let mut apps = command(); - assert!(apps.find_subcommand("share").unwrap().is_hide_set()); + assert!(apps.find_subcommand("share").is_none()); let help = apps.render_long_help().to_string(); assert!(!help.contains("Share a restricted app with a BuilderLab workspace")); - assert!(!help.contains("\n share")); let described = describe_commands(); assert!(!described.to_string().contains("WORKSPACE_ID")); - assert!(described["commands"] - .as_array() - .unwrap() - .iter() - .all(|command| command["name"] != "share")); - let mut completion = completion_command(); - let mut script = Vec::new(); - clap_complete::generate( - clap_complete::Shell::Bash, - &mut completion, - "bl-apps", - &mut script, - ); - assert!(!String::from_utf8(script) - .unwrap() - .contains("__subcmd__share")); } #[test] fn retired_workspace_sharing_never_authenticates_or_reaches_compose() { - for (action, proxy, json) in [ - ("grant", false, true), - ("revoke", false, false), - ("grant", true, false), - ("revoke", true, true), - ] { + for action in ["grant", "revoke"] { let auth_server = ProcessServer::start(vec![]); let control_plane = ProcessServer::start(vec![]); - let mut args = vec!["apps", "share", action, "my-app", "target-workspace"]; - if json { - args.extend([ - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ]); - } - let mut command = process_command( + let output = process_command( &auth_server, &control_plane, - &args, + &[ + "apps", + "share", + action, + "my-app", + "target-workspace", + "--json", + ], "apps-e2e-only.retired-share.session", - ); - if proxy { - command.env("BL_APPS_E2E_PROXY", "true"); - } - let output = command.output().expect("run retired sharing command"); + ) + .output() + .expect("run retired sharing command"); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains(WORKSPACE_SHARING_RETIRED), "{stderr}"); - if json { - assert_eq!( - process_error(&output)["error"]["code"], - "workspace_sharing_retired" - ); - } + assert!(stderr.contains("share"), "{stderr}"); assert!(auth_server.finish().is_empty()); assert!(control_plane.finish().is_empty()); } diff --git a/src/lib.rs b/src/lib.rs index 3108d12..82620e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,9 +159,7 @@ fn run_bl_with_argv(argv: Vec) -> Result<()> { .get_one::("shell") .copied() .context("expected a shell")?; - // clap_complete includes hidden commands, so omit retired Apps stubs from its tree. - let mut command = - build_bl_command().mut_subcommand("apps", |_| bl::apps::completion_command()); + let mut command = build_bl_command(); clap_complete::generate(shell, &mut command, BL_COMMAND_NAME, &mut std::io::stdout()); Ok(()) } From 849749599f6cbb6ef50e0955da09c5b66db15278 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 11:23:34 -0400 Subject: [PATCH 5/7] Simplify app access guidance and community audience checks --- src/bl/apps.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 17d8cf7..46dcc40 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -608,7 +608,9 @@ fn build_app_handoff( .map(|error| handoff_read_failure_reason(error)); let verified = access.ok().filter(|policy| { let string_array = |field| { - policy.get(field).and_then(Value::as_array) + policy + .get(field) + .and_then(Value::as_array) .is_some_and(|values| values.iter().all(Value::is_string)) }; policy.get("ok") == Some(&Value::Bool(true)) @@ -617,14 +619,12 @@ fn build_app_handoff( policy.get("visibility").and_then(Value::as_str), Some("organization" | "restricted") ) - // Compose omits empty grant arrays. Both non-optional revisions - // establish that this response reports workspace AND community grants. - && ["workspace_grant_revision", "community_grant_revision"].iter() - .all(|field| policy.get(field).and_then(Value::as_u64).is_some()) + // Compose omits an empty community list but always reports its revision. + && policy.get("community_grant_revision").and_then(Value::as_u64).is_some() && string_array("viewers") && (policy.get("visibility").and_then(Value::as_str) != Some("restricted") || string_array("effective_viewers")) - && ["shared_workspace_ids", "shared_community_ids", "effective_viewers", "publishers"].iter() + && ["shared_community_ids", "effective_viewers", "publishers"].iter() .all(|field| policy.get(field).is_none() || string_array(field)) }); let reason = verified @@ -2266,7 +2266,7 @@ fn control_plane_http_failure( }) .and_then(|value| sanitize_recovery_field(value, credential)) .or_else(|| (status == StatusCode::FORBIDDEN).then(|| json!( - "Check Apps access for this account and environment, and your workspace and app permissions. Use `bl workspace list` and `bl workspace switch` to check the active workspace." + "Check Apps access for this account and environment, and your workspace and app permissions. Ask the app owner to confirm your viewing or publishing access." ))) }; let mut message = format!("{method} {path} failed with {status} ({code})"); @@ -2496,19 +2496,19 @@ mod tests { } #[test] - fn handoff_requires_complete_workspace_and_community_grant_reporting() { + fn handoff_requires_complete_community_grant_reporting() { let portal = management_portal_url("https://app.builderlab.xyz").unwrap(); let app = json!({"app":{"app_id":"demo","workspace_id":"team"}}); for visibility in ["restricted", "organization"] { - // Compose omits empty grant arrays, but always reports both revisions. + // No legacy workspace-grant fields are needed to confirm the audience. let complete = json!({"ok":true,"app_id":"demo","visibility":visibility, "viewers":[],"effective_viewers":["owner"], - "workspace_grant_revision":0,"community_grant_revision":0}); + "community_grant_revision":0}); let result = build_app_handoff("demo", &portal, &app, Ok(&complete)).unwrap(); assert_eq!(result["audience"]["status"], "verified"); assert_eq!(result["audience"]["policy"], complete); - for field in ["workspace_grant_revision", "community_grant_revision"] { + for field in ["community_grant_revision"] { for invalid in [ None, Some(Value::Null), @@ -2530,7 +2530,6 @@ mod tests { } } for field in [ - "shared_workspace_ids", "shared_community_ids", "viewers", "effective_viewers", @@ -2996,6 +2995,8 @@ mod tests { assert!(!stderr.contains(credential)); assert!(!stderr.contains("private-diagnostic")); assert!(!stderr.contains("apps@example.com")); + assert!(!stderr.contains("bl workspace switch")); + assert!(!stderr.contains("bl workspace list")); if code != "account_setup_required" { assert!(!stderr.contains("Finish account setup")); } From 9f506552f650d8756c523b88258de12156932441 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 11:31:51 -0400 Subject: [PATCH 6/7] Remove obsolete workspace grant test fixtures --- src/bl/apps.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 46dcc40..a2cd2de 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -2484,10 +2484,10 @@ mod tests { #[test] fn handoff_reports_verified_audience_and_exact_management_destination() { let portal = management_portal_url("https://app.builderlabstaging.xyz").unwrap(); - let inspected = json!({"app":{"app_id":"reading-list","workspace_id":"workspace-personal-owner","host":"reading-list.apps.example"}}); - let access = json!({"ok":true,"app_id":"reading-list","visibility":"restricted","viewers":["viewer"],"effective_viewers":["owner","publisher","viewer"],"publishers":["publisher"],"shared_workspace_ids":["team"],"workspace_grant_revision":2,"shared_community_ids":["community"],"community_grant_revision":3}); + let inspected = json!({"app":{"app_id":"reading-list","workspace_id":"workspace-owner","host":"reading-list.apps.example"}}); + let access = json!({"ok":true,"app_id":"reading-list","visibility":"restricted","viewers":["viewer"],"effective_viewers":["owner","publisher","viewer"],"publishers":["publisher"],"shared_community_ids":["community"],"community_grant_revision":3}); let result = build_app_handoff("reading-list", &portal, &inspected, Ok(&access)).unwrap(); - assert_eq!(result["management_url"], "https://app.builderlabstaging.xyz/hosted-apps?app=reading-list&workspace=workspace-personal-owner"); + assert_eq!(result["management_url"], "https://app.builderlabstaging.xyz/hosted-apps?app=reading-list&workspace=workspace-owner"); assert_eq!(result["app_url"], "https://reading-list.apps.example/"); assert_eq!(result["audience"]["status"], "verified"); assert_eq!(result["audience"]["policy"], access); @@ -3138,7 +3138,7 @@ mod tests { for (policy, expected_status, expected_reason) in [ ( ProcessResponse::json( - json!({"ok":true,"app_id":"demo","visibility":"restricted","viewers":[],"effective_viewers":["owner"],"shared_workspace_ids":["team"],"workspace_grant_revision":1,"shared_community_ids":["community"],"community_grant_revision":2}), + json!({"ok":true,"app_id":"demo","visibility":"restricted","viewers":[],"effective_viewers":["owner"],"shared_community_ids":["community"],"community_grant_revision":2}), ), "verified", Value::Null, @@ -3188,7 +3188,7 @@ mod tests { let auth = ProcessServer::start(vec![]); let control = ProcessServer::start(vec![ ProcessResponse::json( - json!({"ok":true,"app":{"app_id":"demo","workspace_id":"owner-personal","host":"demo.apps.example"}}), + json!({"ok":true,"app":{"app_id":"demo","workspace_id":"owning-workspace","host":"demo.apps.example"}}), ), policy, ]); @@ -3217,7 +3217,7 @@ mod tests { let handoff: Value = serde_json::from_str(&process_stdout(&output)).unwrap(); assert_eq!( handoff["management_url"], - "https://app.builderlabstaging.xyz/hosted-apps?app=demo&workspace=owner-personal" + "https://app.builderlabstaging.xyz/hosted-apps?app=demo&workspace=owning-workspace" ); assert_eq!(handoff["recipient_browser_access_verified"], false); assert_eq!(handoff["audience"]["status"], expected_status); @@ -3229,10 +3229,6 @@ mod tests { if expected_status == "unknown" { assert!(handoff["audience"]["policy"].is_null()); } else { - assert_eq!( - handoff["audience"]["policy"]["shared_workspace_ids"], - json!(["team"]) - ); assert_eq!( handoff["audience"]["policy"]["shared_community_ids"], json!(["community"]) From 57647bdc9fae82517306adcf346aee43a239ab38 Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Thu, 29 Oct 2026 13:37:54 -0400 Subject: [PATCH 7/7] Ask the app owner only when a denied request is about one app The generic 403 fallback also covers list, contract and plan requests, which have no app owner to ask. Those now get account, environment and workspace guidance only. Co-Authored-By: Claude Opus 5.5 --- src/bl/apps.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/bl/apps.rs b/src/bl/apps.rs index a2cd2de..8d96ac9 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -2209,6 +2209,13 @@ fn sanitize_recovery_field(value: &Value, credential: &ComposeSessionCredential) Some(value) } +fn is_app_scoped_path(path: &str) -> bool { + let path = path.split('?').next().unwrap_or(path); + path.strip_prefix("/v1/agent/apps/") + .and_then(|rest| rest.split('/').next()) + .is_some_and(|app_id| !app_id.is_empty() && app_id != "plan") +} + fn control_plane_http_failure( method: &str, path: &str, @@ -2265,9 +2272,16 @@ fn control_plane_http_failure( .or_else(|| value.pointer("/error/next_action")) }) .and_then(|value| sanitize_recovery_field(value, credential)) - .or_else(|| (status == StatusCode::FORBIDDEN).then(|| json!( - "Check Apps access for this account and environment, and your workspace and app permissions. Ask the app owner to confirm your viewing or publishing access." - ))) + .or_else(|| { + (status == StatusCode::FORBIDDEN).then(|| { + // Only a request about one app has an owner to ask. + json!(if is_app_scoped_path(path) { + "Check Apps access for this account and environment, and your workspace and app permissions. Ask the app owner to confirm your viewing or publishing access." + } else { + "Check Apps access for this account and environment, and your workspace permissions." + }) + }) + }) }; let mut message = format!("{method} {path} failed with {status} ({code})"); if let Some((diagnostic, guidance)) = validation { @@ -2891,6 +2905,50 @@ mod tests { assert_eq!(requests[0].body, Value::Null); } + #[test] + fn app_scoped_paths_are_requests_about_one_app() { + for path in [ + "/v1/agent/apps/demo", + "/v1/agent/apps/demo?environment=staging", + "/v1/agent/apps/demo/access", + "/v1/agent/apps/merchant%2Flookup%20app/deploy", + ] { + assert!(is_app_scoped_path(path), "{path}"); + } + for path in [ + APPS_CONTRACT_PATH, + APPS_PLAN_PATH, + "/v1/agent/apps", + "/v1/agent/apps?scope=owned", + "/v1/agent/apps/", + ] { + assert!(!is_app_scoped_path(path), "{path}"); + } + } + + #[test] + fn bl_apps_generic_denial_asks_the_owner_only_for_an_app_request() { + let credential = "apps-e2e-only.app-denial.session"; + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::raw(403, "").with_trace_id(REQUEST_TRACE_ID) + ]); + let output = process_command( + &auth_server, + &control_plane, + &["apps", "get", "demo", "--base-url", APPROVED_TEST_BASE_URL], + credential, + ) + .output() + .expect("run app denial"); + assert_eq!(output.status.code(), Some(exit_codes::FORBIDDEN)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Ask the app owner to confirm"), "{stderr}"); + assert!(!stderr.contains("bl workspace"), "{stderr}"); + auth_server.finish(); + assert_eq!(control_plane.finish()[0].path, "/v1/agent/apps/demo"); + } + #[test] fn bl_apps_process_distinguishes_account_setup_from_other_denials() { let credential = "apps-e2e-only.account-access.session"; @@ -2924,28 +2982,28 @@ mod tests { 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({"account_status": "ACCOUNT_STATUS_REJECTED", "enabled_products": []}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({"account_status": "ACCOUNT_STATUS_FUTURE", "enabled_products": []}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( needs_details.clone(), @@ -2997,6 +3055,8 @@ mod tests { assert!(!stderr.contains("apps@example.com")); assert!(!stderr.contains("bl workspace switch")); assert!(!stderr.contains("bl workspace list")); + // Listing apps has no app owner to ask. + assert!(!stderr.contains("Ask the app owner to confirm"), "{stderr}"); if code != "account_setup_required" { assert!(!stderr.contains("Finish account setup")); }