feat: Add plugin share access controls (#21124)

Extends `plugin/share/save` to accept optional discoverability and
shareTargets while uploading plugin contents, and adds
`plugin/share/updateTargets` for share-only target updates without
re-uploading.
This commit is contained in:
xl-openai
2026-05-05 20:14:18 -07:00
committed by Channing Conger
parent 5ef71a8e53
commit a2485575d5
25 changed files with 1206 additions and 2 deletions

View File

@@ -29,10 +29,17 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
pub use share::RemotePluginShareAccessPolicy;
pub use share::RemotePluginShareDiscoverability;
pub use share::RemotePluginSharePrincipal;
pub use share::RemotePluginSharePrincipalType;
pub use share::RemotePluginShareSaveResult;
pub use share::RemotePluginShareTarget;
pub use share::RemotePluginShareUpdateTargetsResult;
pub use share::delete_remote_plugin_share;
pub use share::list_remote_plugin_shares;
pub use share::save_remote_plugin_share;
pub use share::update_remote_plugin_share_targets;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";

View File

@@ -26,6 +26,46 @@ pub struct RemotePluginShareSaveResult {
pub share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RemotePluginShareAccessPolicy {
pub discoverability: Option<RemotePluginShareDiscoverability>,
pub share_targets: Option<Vec<RemotePluginShareTarget>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemotePluginShareDiscoverability {
Listed,
Unlisted,
Private,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemotePluginSharePrincipalType {
User,
Group,
Workspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemotePluginShareTarget {
pub principal_type: RemotePluginSharePrincipalType,
pub principal_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginSharePrincipal {
pub principal_type: RemotePluginSharePrincipalType,
pub principal_id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareUpdateTargetsResult {
pub principals: Vec<RemotePluginSharePrincipal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginUploadUrlRequest<'a> {
filename: &'a str,
@@ -46,6 +86,10 @@ struct RemoteWorkspacePluginUploadUrlResponse {
struct RemoteWorkspacePluginCreateRequest {
file_id: String,
etag: String,
#[serde(skip_serializing_if = "Option::is_none")]
discoverability: Option<RemotePluginShareDiscoverability>,
#[serde(skip_serializing_if = "Option::is_none")]
share_targets: Option<Vec<RemotePluginShareTarget>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -54,12 +98,23 @@ struct RemoteWorkspacePluginCreateResponse {
share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemotePluginShareUpdateTargetsRequest {
targets: Vec<RemotePluginShareTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginShareUpdateTargetsResponse {
principals: Vec<RemotePluginSharePrincipal>,
}
pub async fn save_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
codex_home: &Path,
plugin_path: &AbsolutePathBuf,
remote_plugin_id: Option<&str>,
access_policy: RemotePluginShareAccessPolicy,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
@@ -89,6 +144,8 @@ pub async fn save_remote_plugin_share(
RemoteWorkspacePluginCreateRequest {
file_id: upload.file_id,
etag,
discoverability: access_policy.discoverability,
share_targets: access_policy.share_targets,
},
)
.await?;
@@ -173,6 +230,24 @@ pub async fn delete_remote_plugin_share(
Ok(())
}
pub async fn update_remote_plugin_share_targets(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
targets: Vec<RemotePluginShareTarget>,
) -> Result<RemotePluginShareUpdateTargetsResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares");
let client = build_reqwest_client();
let request = authenticated_request(client.put(&url), auth)?
.json(&RemotePluginShareUpdateTargetsRequest { targets });
let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?;
Ok(RemotePluginShareUpdateTargetsResult {
principals: response.principals,
})
}
async fn fetch_created_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,

View File

@@ -202,6 +202,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
"discoverability": "PRIVATE",
"share_targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "workspace",
"principal_id": "workspace-1",
},
],
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
@@ -217,6 +228,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
codex_home.path(),
&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(),
},
]),
},
)
.await
.unwrap();
@@ -354,6 +378,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
codex_home.path(),
&plugin_path,
Some("plugins_123"),
RemotePluginShareAccessPolicy::default(),
)
.await
.unwrap();
@@ -367,6 +392,83 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
);
}
#[tokio::test]
async fn update_remote_plugin_share_targets_updates_targets() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("PUT"))
.and(path("/backend-api/public/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "group",
"principal_id": "group-1",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "user-1",
"name": "Gavin",
},
{
"principal_type": "group",
"principal_id": "group-1",
"name": "Engineering",
},
],
})))
.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(),
},
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
},
],
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareUpdateTargetsResult {
principals: vec![
RemotePluginSharePrincipal {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
},
RemotePluginSharePrincipal {
principal_type: RemotePluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
name: "Engineering".to_string(),
},
],
}
);
}
#[tokio::test]
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
let codex_home = TempDir::new().unwrap();