mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user