feat: Update plugin share settings with discoverability (#21637)

Requires discoverability on plugin/share/updateTargets so the server can
manage workspace link access consistently, including auto-adding the
workspace principal for UNLISTED.

Also rejects LISTED on share creation and blocks client-supplied
workspace principals while preserving response parsing for LISTED.
This commit is contained in:
xl-openai
2026-05-07 21:28:18 -07:00
committed by GitHub
parent 9cbd4c0371
commit ae15343243
17 changed files with 436 additions and 34 deletions
+1
View File
@@ -35,6 +35,7 @@ pub use share::RemotePluginSharePrincipal;
pub use share::RemotePluginSharePrincipalType;
pub use share::RemotePluginShareSaveResult;
pub use share::RemotePluginShareTarget;
pub use share::RemotePluginShareUpdateDiscoverability;
pub use share::RemotePluginShareUpdateTargetsResult;
pub use share::delete_remote_plugin_share;
pub use share::list_remote_plugin_shares;
+63 -5
View File
@@ -32,7 +32,7 @@ pub struct RemotePluginShareAccessPolicy {
pub share_targets: Option<Vec<RemotePluginShareTarget>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemotePluginShareDiscoverability {
Listed,
@@ -40,6 +40,13 @@ pub enum RemotePluginShareDiscoverability {
Private,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemotePluginShareUpdateDiscoverability {
Unlisted,
Private,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemotePluginSharePrincipalType {
@@ -64,6 +71,7 @@ pub struct RemotePluginSharePrincipal {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareUpdateTargetsResult {
pub principals: Vec<RemotePluginSharePrincipal>,
pub discoverability: RemotePluginShareDiscoverability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -100,12 +108,14 @@ struct RemoteWorkspacePluginCreateResponse {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemotePluginShareUpdateTargetsRequest {
discoverability: RemotePluginShareUpdateDiscoverability,
targets: Vec<RemotePluginShareTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginShareUpdateTargetsResponse {
principals: Vec<RemotePluginSharePrincipal>,
discoverability: Option<RemotePluginShareDiscoverability>,
}
pub async fn save_remote_plugin_share(
@@ -137,6 +147,9 @@ pub async fn save_remote_plugin_share(
.etag
.ok_or(RemotePluginCatalogError::MissingUploadEtag)?;
put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?;
let share_targets = access_policy.share_targets;
let share_targets =
ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?;
let response = finalize_workspace_plugin_upload(
config,
auth,
@@ -145,7 +158,7 @@ pub async fn save_remote_plugin_share(
file_id: upload.file_id,
etag,
discoverability: access_policy.discoverability,
share_targets: access_policy.share_targets,
share_targets,
},
)
.await?;
@@ -245,19 +258,64 @@ pub async fn update_remote_plugin_share_targets(
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
targets: Vec<RemotePluginShareTarget>,
discoverability: RemotePluginShareUpdateDiscoverability,
) -> Result<RemotePluginShareUpdateTargetsResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let target_discoverability = match discoverability {
RemotePluginShareUpdateDiscoverability::Unlisted => {
RemotePluginShareDiscoverability::Unlisted
}
RemotePluginShareUpdateDiscoverability::Private => {
RemotePluginShareDiscoverability::Private
}
};
let targets =
ensure_unlisted_workspace_target(auth, Some(target_discoverability), Some(targets))?
.unwrap_or_default();
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares");
let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares");
let client = build_reqwest_client();
let request = authenticated_request(client.put(&url), auth)?
.json(&RemotePluginShareUpdateTargetsRequest { targets });
let request = authenticated_request(client.put(&url), auth)?.json(
&RemotePluginShareUpdateTargetsRequest {
discoverability,
targets,
},
);
let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?;
Ok(RemotePluginShareUpdateTargetsResult {
principals: response.principals,
// TODO: Remove this fallback once deployed plugin-service responses always include
// discoverability per the API schema.
discoverability: response.discoverability.unwrap_or(target_discoverability),
})
}
fn ensure_unlisted_workspace_target(
auth: &CodexAuth,
discoverability: Option<RemotePluginShareDiscoverability>,
targets: Option<Vec<RemotePluginShareTarget>>,
) -> Result<Option<Vec<RemotePluginShareTarget>>, RemotePluginCatalogError> {
if discoverability != Some(RemotePluginShareDiscoverability::Unlisted) {
return Ok(targets);
}
let account_id = auth.get_account_id().ok_or_else(|| {
RemotePluginCatalogError::UnexpectedResponse(
"workspace plugin share requires an account id".to_string(),
)
})?;
let mut targets = targets.unwrap_or_default();
if !targets.iter().any(|target| {
target.principal_type == RemotePluginSharePrincipalType::Workspace
&& target.principal_id == account_id
}) {
targets.push(RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::Workspace,
principal_id: account_id,
});
}
Ok(Some(targets))
}
async fn fetch_created_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
+74 -14
View File
@@ -204,7 +204,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
"discoverability": "PRIVATE",
"discoverability": "UNLISTED",
"share_targets": [
{
"principal_type": "user",
@@ -212,7 +212,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
},
{
"principal_type": "workspace",
"principal_id": "workspace-1",
"principal_id": "account_id",
},
],
})))
@@ -231,17 +231,11 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
&plugin_path,
/*remote_plugin_id*/ None,
RemotePluginShareAccessPolicy {
discoverability: Some(RemotePluginShareDiscoverability::Private),
share_targets: Some(vec![
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
},
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::Workspace,
principal_id: "workspace-1".to_string(),
},
]),
discoverability: Some(RemotePluginShareDiscoverability::Unlisted),
share_targets: Some(vec![RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
}]),
},
)
.await
@@ -401,10 +395,11 @@ async fn update_remote_plugin_share_targets_updates_targets() {
let auth = test_auth();
Mock::given(method("PUT"))
.and(path("/backend-api/public/plugins/plugins_123/shares"))
.and(path("/backend-api/ps/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"discoverability": "UNLISTED",
"targets": [
{
"principal_type": "user",
@@ -414,6 +409,10 @@ async fn update_remote_plugin_share_targets_updates_targets() {
"principal_type": "group",
"principal_id": "group-1",
},
{
"principal_type": "workspace",
"principal_id": "account_id",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -429,6 +428,7 @@ async fn update_remote_plugin_share_targets_updates_targets() {
"name": "Engineering",
},
],
"discoverability": "UNLISTED",
})))
.expect(1)
.mount(&server)
@@ -448,6 +448,7 @@ async fn update_remote_plugin_share_targets_updates_targets() {
principal_id: "group-1".to_string(),
},
],
RemotePluginShareUpdateDiscoverability::Unlisted,
)
.await
.unwrap();
@@ -467,6 +468,65 @@ async fn update_remote_plugin_share_targets_updates_targets() {
name: "Engineering".to_string(),
},
],
discoverability: RemotePluginShareDiscoverability::Unlisted,
}
);
}
#[tokio::test]
async fn update_remote_plugin_share_targets_falls_back_to_requested_discoverability() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("PUT"))
.and(path("/backend-api/ps/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"discoverability": "PRIVATE",
"targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "user-1",
"name": "Gavin",
},
],
})))
.expect(1)
.mount(&server)
.await;
let result = update_remote_plugin_share_targets(
&config,
Some(&auth),
"plugins_123",
vec![RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
}],
RemotePluginShareUpdateDiscoverability::Private,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareUpdateTargetsResult {
principals: vec![RemotePluginSharePrincipal {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
}],
discoverability: RemotePluginShareDiscoverability::Private,
}
);
}