feat: Add marketplace source filtering and plugin share context (#21419)

Adds marketplaceKinds to plugin/list for local, workspace-directory, and
shared-with-me; omitted params keep default local plus gated global
behavior, while explicit kinds are exact.

Exposes shareContext on plugin summaries from local share mappings and
remote workspace/shared responses, including remotePluginId and nullable
creator metadata.

Adds shared-with-me listing through /ps/plugins/workspace/shared,
renames the workspace remote namespace to workspace-directory, and keeps
direct remote read/share/install/update/delete paths gated by plugins
rather than remote_plugin.
This commit is contained in:
xl-openai
2026-05-06 16:12:23 -07:00
committed by GitHub
parent 9417cf9696
commit 11106016ff
28 changed files with 1357 additions and 201 deletions
+179 -93
View File
@@ -38,13 +38,16 @@ 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::load_plugin_share_remote_ids_by_local_path;
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";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";
pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "shared-with-me";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";
pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
@@ -63,6 +66,13 @@ pub struct RemoteMarketplace {
pub plugins: Vec<RemotePluginSummary>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteMarketplaceSource {
Global,
WorkspaceDirectory,
SharedWithMe,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemoteInstalledPlugin {
pub marketplace_name: String,
@@ -75,6 +85,7 @@ pub struct RemoteInstalledPlugin {
pub struct RemotePluginSummary {
pub id: String,
pub name: String,
pub share_context: Option<RemotePluginShareContext>,
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
@@ -84,6 +95,13 @@ pub struct RemotePluginSummary {
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareContext {
pub remote_plugin_id: String,
pub creator_account_user_id: Option<String>,
pub creator_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginShareSummary {
pub summary: RemotePluginSummary,
@@ -237,10 +255,6 @@ enum RemotePluginScope {
}
impl RemotePluginScope {
fn all() -> [Self; 2] {
[Self::Global, Self::Workspace]
}
fn api_value(self) -> &'static str {
match self {
Self::Global => "GLOBAL",
@@ -265,7 +279,9 @@ impl RemotePluginScope {
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => {
Some(Self::Workspace)
}
_ => None,
}
}
@@ -342,6 +358,10 @@ struct RemotePluginDirectoryItem {
name: String,
scope: RemotePluginScope,
#[serde(default)]
creator_account_user_id: Option<String>,
#[serde(default)]
creator_name: Option<String>,
#[serde(default)]
share_url: Option<String>,
installation_policy: PluginInstallPolicy,
authentication_policy: PluginAuthPolicy,
@@ -380,101 +400,119 @@ struct RemotePluginMutationResponse {
pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
sources: &[RemoteMarketplaceSource],
) -> Result<Vec<RemoteMarketplace>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let mut directory_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginDirectoryItem>>::new();
let mut installed_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginInstalledItem>>::new();
let global = async {
let scope = RemotePluginScope::Global;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let workspace = async {
let scope = RemotePluginScope::Workspace;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
for (scope, directory_plugins, installed_plugins) in [global, workspace] {
if !directory_plugins.is_empty() {
directory_by_scope.insert(
scope,
directory_plugins
.into_iter()
.map(|plugin| (plugin.id.clone(), plugin))
.collect(),
);
}
if !installed_plugins.is_empty() {
installed_by_scope.insert(
scope,
installed_plugins
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect(),
);
}
}
let mut marketplaces = Vec::new();
for scope in RemotePluginScope::all() {
let directory_plugins = directory_by_scope.get(&scope);
let installed_plugins = installed_by_scope.get(&scope);
let plugin_ids = directory_plugins
.into_iter()
.flat_map(|plugins| plugins.keys())
.chain(
installed_plugins
.into_iter()
.flat_map(|plugins| plugins.keys()),
)
.cloned()
.collect::<BTreeSet<_>>();
if plugin_ids.is_empty() {
continue;
}
let needs_workspace_installed = sources.iter().any(|source| {
matches!(
source,
RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe
)
});
let workspace_installed_plugins = if needs_workspace_installed {
Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?)
} else {
None
};
let mut plugins = plugin_ids
.into_iter()
.filter_map(|plugin_id| {
let directory_plugin =
directory_plugins.and_then(|plugins| plugins.get(&plugin_id));
let installed_plugin =
installed_plugins.and_then(|plugins| plugins.get(&plugin_id));
directory_plugin
.or_else(|| installed_plugin.map(|plugin| &plugin.plugin))
.map(|plugin| build_remote_plugin_summary(plugin, installed_plugin))
})
.collect::<Vec<_>>();
plugins.sort_by(|left, right| {
remote_plugin_display_name(left)
.to_ascii_lowercase()
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
.then_with(|| {
remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))
})
.then_with(|| left.id.cmp(&right.id))
});
marketplaces.push(RemoteMarketplace {
name: scope.marketplace_name().to_string(),
display_name: scope.marketplace_display_name().to_string(),
plugins,
});
for source in sources {
let marketplace = match source {
RemoteMarketplaceSource::Global => {
let scope = RemotePluginScope::Global;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
directory_plugins,
installed_plugins,
/*include_installed_only*/ true,
)
}
RemoteMarketplaceSource::WorkspaceDirectory => {
let scope = RemotePluginScope::Workspace;
let directory_plugins =
fetch_directory_plugins_for_scope(config, auth, scope).await?;
build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
directory_plugins,
workspace_installed_plugins.clone().unwrap_or_default(),
/*include_installed_only*/ false,
)
}
RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace(
REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME,
fetch_shared_workspace_plugins(config, auth).await?,
workspace_installed_plugins.clone().unwrap_or_default(),
/*include_installed_only*/ false,
),
};
if let Some(marketplace) = marketplace {
marketplaces.push(marketplace);
}
}
Ok(marketplaces)
}
fn build_remote_marketplace(
name: &str,
display_name: &str,
directory_plugins: Vec<RemotePluginDirectoryItem>,
installed_plugins: Vec<RemotePluginInstalledItem>,
include_installed_only: bool,
) -> Option<RemoteMarketplace> {
let directory_plugins = directory_plugins
.into_iter()
.map(|plugin| (plugin.id.clone(), plugin))
.collect::<BTreeMap<_, _>>();
let installed_plugins = installed_plugins
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect::<BTreeMap<_, _>>();
let plugin_ids = directory_plugins
.keys()
.chain(
include_installed_only
.then_some(&installed_plugins)
.into_iter()
.flat_map(|plugins| plugins.keys()),
)
.cloned()
.collect::<BTreeSet<_>>();
if plugin_ids.is_empty() {
return None;
}
let mut plugins = plugin_ids
.into_iter()
.filter_map(|plugin_id| {
let directory_plugin = directory_plugins.get(&plugin_id);
let installed_plugin = installed_plugins.get(&plugin_id);
directory_plugin
.or_else(|| installed_plugin.map(|plugin| &plugin.plugin))
.map(|plugin| build_remote_plugin_summary(plugin, installed_plugin))
})
.collect::<Vec<_>>();
plugins.sort_by(|left, right| {
remote_plugin_display_name(left)
.to_ascii_lowercase()
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
.then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)))
.then_with(|| left.id.cmp(&right.id))
});
Some(RemoteMarketplace {
name: name.to_string(),
display_name: display_name.to_string(),
plugins,
})
}
pub async fn fetch_remote_installed_plugins(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -775,6 +813,7 @@ fn build_remote_plugin_summary(
RemotePluginSummary {
id: plugin.id.clone(),
name: plugin.name.clone(),
share_context: remote_plugin_share_context(plugin),
installed: installed_plugin.is_some(),
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
install_policy: plugin.installation_policy,
@@ -785,6 +824,19 @@ fn build_remote_plugin_summary(
}
}
fn remote_plugin_share_context(
plugin: &RemotePluginDirectoryItem,
) -> Option<RemotePluginShareContext> {
match plugin.scope {
RemotePluginScope::Global => None,
RemotePluginScope::Workspace => Some(RemotePluginShareContext {
remote_plugin_id: plugin.id.clone(),
creator_account_user_id: plugin.creator_account_user_id.clone(),
creator_name: plugin.creator_name.clone(),
}),
}
}
fn remote_installed_plugin_to_info(
scope: RemotePluginScope,
installed_plugin: &RemotePluginInstalledItem,
@@ -906,6 +958,24 @@ async fn fetch_directory_plugins_for_scope(
Ok(plugins)
}
async fn fetch_shared_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
let mut plugins = Vec::new();
let mut page_token = None;
loop {
let response =
get_remote_shared_workspace_plugins_page(config, auth, page_token.as_deref()).await?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
};
page_token = Some(next_page_token);
}
Ok(plugins)
}
async fn fetch_installed_plugins_for_scope(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
@@ -961,6 +1031,22 @@ async fn get_remote_plugin_list_page(
send_and_decode(request, &url).await
}
async fn get_remote_shared_workspace_plugins_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/workspace/shared");
let client = build_reqwest_client();
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]);
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
send_and_decode(request, &url).await
}
async fn get_remote_plugin_installed_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
+10
View File
@@ -209,6 +209,16 @@ pub async fn list_remote_plugin_shares(
.collect())
}
pub fn load_plugin_share_remote_ids_by_local_path(
codex_home: &Path,
) -> io::Result<BTreeMap<AbsolutePathBuf, String>> {
let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?;
Ok(local_paths
.into_iter()
.map(|(remote_plugin_id, local_plugin_path)| (local_plugin_path, remote_plugin_id))
.collect())
}
pub async fn delete_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -538,6 +538,11 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
summary: RemotePluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_123".to_string(),
creator_account_user_id: None,
creator_name: None,
}),
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
@@ -553,6 +558,11 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
summary: RemotePluginSummary {
id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_456".to_string(),
creator_account_user_id: None,
creator_name: None,
}),
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,