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
Unverified
parent 9417cf9696
commit 11106016ff
28 changed files with 1357 additions and 201 deletions
@@ -2144,6 +2144,14 @@
],
"type": "object"
},
"PluginListMarketplaceKind": {
"enum": [
"local",
"workspace-directory",
"shared-with-me"
],
"type": "string"
},
"PluginListParams": {
"properties": {
"cwds": {
@@ -2155,6 +2163,16 @@
"array",
"null"
]
},
"marketplaceKinds": {
"description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.",
"items": {
"$ref": "#/definitions/PluginListMarketplaceKind"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
@@ -12353,6 +12353,14 @@
],
"type": "object"
},
"PluginListMarketplaceKind": {
"enum": [
"local",
"workspace-directory",
"shared-with-me"
],
"type": "string"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12365,6 +12373,16 @@
"array",
"null"
]
},
"marketplaceKinds": {
"description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.",
"items": {
"$ref": "#/definitions/v2/PluginListMarketplaceKind"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
@@ -12481,6 +12499,29 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
"type": [
"string",
"null"
]
},
"creatorName": {
"type": [
"string",
"null"
]
},
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12845,6 +12886,17 @@
"name": {
"type": "string"
},
"shareContext": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginShareContext"
},
{
"type": "null"
}
],
"description": "Remote sharing context associated with this plugin when available."
},
"source": {
"$ref": "#/definitions/v2/PluginSource"
}
@@ -8964,6 +8964,14 @@
],
"type": "object"
},
"PluginListMarketplaceKind": {
"enum": [
"local",
"workspace-directory",
"shared-with-me"
],
"type": "string"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -8976,6 +8984,16 @@
"array",
"null"
]
},
"marketplaceKinds": {
"description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.",
"items": {
"$ref": "#/definitions/PluginListMarketplaceKind"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
@@ -9092,6 +9110,29 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
"type": [
"string",
"null"
]
},
"creatorName": {
"type": [
"string",
"null"
]
},
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9456,6 +9497,17 @@
"name": {
"type": "string"
},
"shareContext": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareContext"
},
{
"type": "null"
}
],
"description": "Remote sharing context associated with this plugin when available."
},
"source": {
"$ref": "#/definitions/PluginSource"
}
@@ -4,6 +4,14 @@
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"PluginListMarketplaceKind": {
"enum": [
"local",
"workspace-directory",
"shared-with-me"
],
"type": "string"
}
},
"properties": {
@@ -16,6 +24,16 @@
"array",
"null"
]
},
"marketplaceKinds": {
"description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.",
"items": {
"$ref": "#/definitions/PluginListMarketplaceKind"
},
"type": [
"array",
"null"
]
}
},
"title": "PluginListParams",
@@ -232,6 +232,29 @@
],
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
"type": [
"string",
"null"
]
},
"creatorName": {
"type": [
"string",
"null"
]
},
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -357,6 +380,17 @@
"name": {
"type": "string"
},
"shareContext": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareContext"
},
{
"type": "null"
}
],
"description": "Remote sharing context associated with this plugin when available."
},
"source": {
"$ref": "#/definitions/PluginSource"
}
@@ -251,6 +251,29 @@
],
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
"type": [
"string",
"null"
]
},
"creatorName": {
"type": [
"string",
"null"
]
},
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -376,6 +399,17 @@
"name": {
"type": "string"
},
"shareContext": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareContext"
},
{
"type": "null"
}
],
"description": "Remote sharing context associated with this plugin when available."
},
"source": {
"$ref": "#/definitions/PluginSource"
}
@@ -167,6 +167,29 @@
],
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
"type": [
"string",
"null"
]
},
"creatorName": {
"type": [
"string",
"null"
]
},
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
@@ -317,6 +340,17 @@
"name": {
"type": "string"
},
"shareContext": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareContext"
},
{
"type": "null"
}
],
"description": "Remote sharing context associated with this plugin when available."
},
"source": {
"$ref": "#/definitions/PluginSource"
}
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me";
@@ -2,10 +2,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind";
export type PluginListParams = {
/**
* Optional working directories used to discover repo marketplaces. When omitted,
* only home-scoped marketplaces and the official curated marketplace are considered.
*/
cwds?: Array<AbsolutePathBuf> | null, };
cwds?: Array<AbsolutePathBuf> | null,
/**
* Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus
* the default remote catalog when enabled by feature flag.
*/
marketplaceKinds?: Array<PluginListMarketplaceKind> | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginShareContext = { remotePluginId: string, creatorAccountUserId: string | null, creatorName: string | null, };
@@ -5,9 +5,14 @@ import type { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginAvailability } from "./PluginAvailability";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInterface } from "./PluginInterface";
import type { PluginShareContext } from "./PluginShareContext";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy,
export type PluginSummary = { id: string, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy,
/**
* Availability state for installing and using the plugin.
*/
@@ -278,11 +278,13 @@ export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInterface } from "./PluginInterface";
export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind";
export type { PluginListParams } from "./PluginListParams";
export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareContext } from "./PluginShareContext";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
@@ -132,6 +132,24 @@ pub struct PluginListParams {
/// only home-scoped marketplaces and the official curated marketplace are considered.
#[ts(optional = nullable)]
pub cwds: Option<Vec<AbsolutePathBuf>>,
/// Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus
/// the default remote catalog when enabled by feature flag.
#[ts(optional = nullable)]
pub marketplace_kinds: Option<Vec<PluginListMarketplaceKind>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginListMarketplaceKind {
#[serde(rename = "local")]
#[ts(rename = "local")]
Local,
#[serde(rename = "workspace-directory")]
#[ts(rename = "workspace-directory")]
WorkspaceDirectory,
#[serde(rename = "shared-with-me")]
#[ts(rename = "shared-with-me")]
SharedWithMe,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -501,6 +519,8 @@ pub enum PluginAvailability {
pub struct PluginSummary {
pub id: String,
pub name: String,
/// Remote sharing context associated with this plugin when available.
pub share_context: Option<PluginShareContext>,
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
@@ -514,6 +534,15 @@ pub struct PluginSummary {
pub keywords: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareContext {
pub remote_plugin_id: String,
pub creator_account_user_id: Option<String>,
pub creator_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2843,7 +2843,33 @@ fn plugin_list_params_ignore_removed_force_remote_sync_field() {
"forceRemoteSync": true,
}))
.unwrap(),
PluginListParams { cwds: None },
PluginListParams {
cwds: None,
marketplace_kinds: None,
},
);
}
#[test]
fn plugin_list_params_serializes_marketplace_kind_filter() {
assert_eq!(
serde_json::to_value(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![
PluginListMarketplaceKind::Local,
PluginListMarketplaceKind::WorkspaceDirectory,
PluginListMarketplaceKind::SharedWithMe,
]),
})
.unwrap(),
json!({
"cwds": null,
"marketplaceKinds": [
"local",
"workspace-directory",
"shared-with-me",
],
}),
);
}
@@ -3099,6 +3125,7 @@ fn plugin_share_list_response_serializes_share_items() {
plugin: PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
name: "gmail".to_string(),
share_context: None,
source: PluginSource::Remote,
installed: false,
enabled: false,
@@ -3118,6 +3145,7 @@ fn plugin_share_list_response_serializes_share_items() {
"plugin": {
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"shareContext": null,
"source": { "type": "remote" },
"installed": false,
"enabled": false,
@@ -3149,6 +3177,7 @@ fn plugin_summary_defaults_missing_availability_to_available() {
.unwrap();
assert_eq!(summary.availability, PluginAvailability::Available);
assert_eq!(summary.share_context, None);
}
#[test]
@@ -109,11 +109,13 @@ use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListMarketplaceKind;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareDiscoverability;
@@ -302,9 +304,11 @@ use codex_core_plugins::marketplace_remove::MarketplaceRemoveError;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_core_plugins::remote::RemoteMarketplace;
use codex_core_plugins::remote::RemoteMarketplaceSource;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginShareContext;
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
@@ -86,6 +86,35 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS
}
}
fn load_shared_plugin_ids_by_local_path(
config: &Config,
) -> std::collections::BTreeMap<AbsolutePathBuf, String> {
codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path(
config.codex_home.as_path(),
)
.unwrap_or_else(|err| {
warn!("failed to load plugin share local path mapping: {err}");
std::collections::BTreeMap::new()
})
}
fn share_context_for_source(
source: &MarketplacePluginSource,
shared_plugin_ids_by_local_path: &std::collections::BTreeMap<AbsolutePathBuf, String>,
) -> Option<PluginShareContext> {
match source {
MarketplacePluginSource::Local { path } => shared_plugin_ids_by_local_path
.get(path)
.cloned()
.map(|remote_plugin_id| PluginShareContext {
remote_plugin_id,
creator_account_user_id: None,
creator_name: None,
}),
MarketplacePluginSource::Git { .. } => None,
}
}
fn remote_plugin_share_discoverability(
discoverability: PluginShareDiscoverability,
) -> codex_core_plugins::remote::RemotePluginShareDiscoverability {
@@ -320,8 +349,15 @@ impl PluginRequestProcessor {
params: PluginListParams,
) -> Result<PluginListResponse, JSONRPCErrorError> {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginListParams { cwds } = params;
let PluginListParams {
cwds,
marketplace_kinds,
} = params;
let roots = cwds.unwrap_or_default();
let explicit_marketplace_kinds = marketplace_kinds.is_some();
let marketplace_kinds =
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let empty_response = || PluginListResponse {
@@ -340,80 +376,109 @@ impl PluginRequestProcessor {
return Ok(empty_response());
}
let plugins_input = config.plugins_config_input();
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback()),
);
let (mut data, marketplace_load_errors) = if include_local {
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback()),
);
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
path: Some(marketplace.path),
interface: marketplace.interface.map(|interface| MarketplaceInterface {
display_name: interface.display_name,
}),
plugins: marketplace
.plugins
.into_iter()
.map(|plugin| PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: plugin.interface.map(local_plugin_interface_to_info),
keywords: plugin.keywords,
})
.collect(),
})
.collect(),
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(outcome)) => outcome,
Ok(Err(err)) => return Err(Self::marketplace_error(err, "list marketplace plugins")),
Err(err) => {
return Err(internal_error(format!(
"failed to list marketplace plugins: {err}"
)));
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config);
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
path: Some(marketplace.path),
interface: marketplace.interface.map(|interface| {
MarketplaceInterface {
display_name: interface.display_name,
}
}),
plugins: marketplace
.plugins
.into_iter()
.map(|plugin| {
let share_context = share_context_for_source(
&plugin.source,
&shared_plugin_ids_by_local_path,
);
PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
share_context,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: plugin
.interface
.map(local_plugin_interface_to_info),
keywords: plugin.keywords,
}
})
.collect(),
})
.collect(),
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(outcome)) => outcome,
Ok(Err(err)) => {
return Err(Self::marketplace_error(err, "list marketplace plugins"));
}
Err(err) => {
return Err(internal_error(format!(
"failed to list marketplace plugins: {err}"
)));
}
}
} else {
(Vec::new(), Vec::new())
};
if config.features.enabled(Feature::RemotePlugin) {
let mut remote_sources = Vec::new();
if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) {
remote_sources.push(RemoteMarketplaceSource::Global);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
remote_sources.push(RemoteMarketplaceSource::SharedWithMe);
}
if !remote_sources.is_empty() {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
match codex_core_plugins::remote::fetch_remote_marketplaces(
&remote_plugin_service_config,
auth.as_ref(),
&remote_sources,
)
.await
{
@@ -509,6 +574,11 @@ impl PluginRequestProcessor {
.read_plugin_for_config(&plugins_input, &request)
.await
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config);
let share_context = share_context_for_source(
&outcome.plugin.source,
&shared_plugin_ids_by_local_path,
);
let environment_manager = self.thread_manager.environment_manager();
let app_summaries =
load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager)
@@ -530,6 +600,7 @@ impl PluginRequestProcessor {
summary: PluginSummary {
id: outcome.plugin.id,
name: outcome.plugin.name,
share_context,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
installed: outcome.plugin.installed,
enabled: outcome.plugin.enabled,
@@ -549,9 +620,7 @@ impl PluginRequestProcessor {
}
}
Err(remote_marketplace_name) => {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
)));
@@ -598,9 +667,7 @@ impl PluginRequestProcessor {
} = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
)));
@@ -778,9 +845,7 @@ impl PluginRequestProcessor {
&self,
) -> Result<(Config, Option<CodexAuth>), JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("plugin sharing is not enabled"));
}
let auth = self.auth_manager.auth().await;
@@ -873,9 +938,7 @@ impl PluginRequestProcessor {
remote_plugin_id: String,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
@@ -1222,9 +1285,7 @@ impl PluginRequestProcessor {
plugin_id: String,
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
validate_remote_plugin_id(&plugin_id)?;
@@ -1384,6 +1445,9 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
PluginSummary {
id: summary.id,
name: summary.name,
share_context: summary
.share_context
.map(remote_plugin_share_context_to_info),
source: PluginSource::Remote,
installed: summary.installed,
enabled: summary.enabled,
@@ -1395,6 +1459,16 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
}
}
fn remote_plugin_share_context_to_info(
context: RemoteCatalogPluginShareContext,
) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: context.remote_plugin_id,
creator_account_user_id: context.creator_account_user_id,
creator_name: context.creator_name,
}
}
fn remote_plugin_detail_to_info(
detail: RemoteCatalogPluginDetail,
apps: Vec<AppSummary>,
@@ -155,7 +155,10 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl
assert_eq!(notification.method, "externalAgentConfig/import/completed");
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -153,8 +153,14 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> {
}
#[tokio::test]
async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> {
async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = false
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -9,6 +9,7 @@ use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListMarketplaceKind;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
@@ -90,6 +91,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -218,6 +220,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
AbsolutePathBuf::try_from(valid_repo_root.path())?,
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
]),
marketplace_kinds: None,
})
.await?;
@@ -237,6 +240,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
path: valid_plugin_path,
},
@@ -328,6 +332,7 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -419,6 +424,7 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<(
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -502,6 +508,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -522,6 +529,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
path: valid_plugin_path,
},
@@ -554,6 +562,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
name: "missing-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
path: AbsolutePathBuf::try_from(
repo_root.path().join("plugins/missing-plugin"),
@@ -606,7 +615,10 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -618,6 +630,73 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let plugin_root = repo_root.path().join("plugins/demo-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
)?;
write_plugin_share_local_path_mapping(
codex_home.path(),
"plugins_123",
&AbsolutePathBuf::try_from(plugin_root)?,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let plugin = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
let share_context = plugin
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.creator_account_user_id, None);
assert_eq!(share_context.creator_name, None);
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -677,6 +756,7 @@ enabled = false
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -833,6 +913,7 @@ enabled = false
AbsolutePathBuf::try_from(workspace_enabled.path())?,
AbsolutePathBuf::try_from(workspace_default.path())?,
]),
marketplace_kinds: None,
})
.await?;
@@ -916,6 +997,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -1028,6 +1110,7 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
})
.await?;
@@ -1101,7 +1184,10 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
.await?;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -1234,7 +1320,10 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -1391,7 +1480,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1445,6 +1537,208 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
Ok(())
}
#[tokio::test]
async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit()
-> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert!(
response
.marketplaces
.iter()
.all(|marketplace| marketplace.name != "chatgpt-global")
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let workspace_plugin_body = workspace_remote_plugin_page_body(
"plugins~Plugin_11111111111111111111111111111111",
"workspace-linear",
"Workspace Linear",
/*enabled*/ None,
);
let workspace_installed_body = workspace_remote_plugin_page_body(
"plugins~Plugin_11111111111111111111111111111111",
"workspace-linear",
"Workspace Linear",
/*enabled*/ Some(false),
);
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
let marketplace = &response.marketplaces[0];
assert_eq!(marketplace.name, "workspace-directory");
assert_eq!(
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Workspace Directory")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].name, "workspace-linear");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, false);
assert!(
!server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.any(|request| request
.url
.query()
.is_some_and(|query| query.contains("scope=GLOBAL")))
);
Ok(())
}
#[tokio::test]
async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let shared_plugin_body = workspace_remote_plugin_page_body(
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
/*enabled*/ None,
);
let workspace_installed_body = workspace_remote_plugin_page_body(
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
/*enabled*/ Some(true),
);
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
let marketplace = &response.marketplaces[0];
assert_eq!(marketplace.name, "shared-with-me");
assert_eq!(
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Shared with me")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].name, "shared-linear");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
let share_context = marketplace.plugins[0]
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_22222222222222222222222222222222"
);
assert_eq!(
share_context.creator_account_user_id.as_deref(),
Some("user-gavin__account-123")
);
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1550,7 +1844,10 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1675,7 +1972,10 @@ async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_nam
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1731,7 +2031,10 @@ remote_plugin = true
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1764,7 +2067,10 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1801,7 +2107,10 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1902,6 +2211,17 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str)
.await;
}
async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/shared"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
}
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
@@ -1923,6 +2243,44 @@ fn empty_remote_installed_plugins_body() -> &'static str {
}"#
}
fn workspace_remote_plugin_page_body(
remote_plugin_id: &str,
plugin_name: &str,
display_name: &str,
enabled: Option<bool>,
) -> String {
let enabled_field = enabled
.map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#))
.unwrap_or_default();
format!(
r#"{{
"plugins": [
{{
"id": "{remote_plugin_id}",
"name": "{plugin_name}",
"scope": "WORKSPACE",
"creator_account_user_id": "user-gavin__account-123",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"creator_name": "Gavin",
"release": {{
"display_name": "{display_name}",
"description": "Track work",
"app_ids": [],
"interface": {{}},
"skills": []
}}{enabled_field}
}}
],
"pagination": {{
"limit": 50,
"next_page_token": null
}}
}}"#
)
}
fn remote_installed_plugin_body(
bundle_download_url: &str,
release_version: &str,
@@ -2121,3 +2479,24 @@ fn write_openai_curated_marketplace(
)?;
Ok(())
}
fn write_plugin_share_local_path_mapping(
codex_home: &std::path::Path,
remote_plugin_id: &str,
plugin_path: &AbsolutePathBuf,
) -> std::io::Result<()> {
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
local_plugin_paths_by_remote_plugin_id.insert(
remote_plugin_id.to_string(),
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
);
let contents = serde_json::to_string_pretty(&serde_json::json!({
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
}))
.map_err(std::io::Error::other)?;
std::fs::create_dir_all(codex_home.join(".tmp"))?;
std::fs::write(
codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
format!("{contents}\n"),
)
}
+248 -10
View File
@@ -116,8 +116,74 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
}
#[tokio::test]
async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> {
async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api/"
[features]
plugins = true
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let detail_body = r#"{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"keywords": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": []
},
"skills": []
}
}"#;
let installed_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
Mock::given(method("GET"))
.and(path(
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",
))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -125,23 +191,121 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "sample-plugin".to_string(),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
let err = timeout(
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("remote plugin read is not enabled")
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
assert_eq!(
response.plugin.summary.id,
"plugins~Plugin_00000000000000000000000000000000"
);
assert!(err.error.message.contains("chatgpt-global"));
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.share_context, None);
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let detail_body = r#"{
"id": "plugins~Plugin_11111111111111111111111111111111",
"name": "shared-linear",
"scope": "WORKSPACE",
"creator_account_user_id": "user-gavin__account-123",
"creator_name": "Gavin",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Shared Linear",
"description": "Track shared work",
"app_ids": [],
"keywords": [],
"interface": {},
"skills": []
}
}"#;
let installed_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
Mock::given(method("GET"))
.and(path(
"/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111",
))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("shared-with-me".to_string()),
plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_11111111111111111111111111111111"
);
assert_eq!(
share_context.creator_account_user_id.as_deref(),
Some("user-gavin__account-123")
);
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
Ok(())
}
@@ -553,6 +717,59 @@ enabled = true
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"codex-curated",
"demo-plugin",
"./demo-plugin",
)?;
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
std::fs::write(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
)?;
write_plugins_enabled_config(&codex_home)?;
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: Some(AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)?),
remote_marketplace_name: None,
plugin_name: "demo-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.creator_account_user_id, None);
assert_eq!(share_context.creator_name, None);
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1348,3 +1565,24 @@ fn write_plugin_source(
)?;
Ok(())
}
fn write_plugin_share_local_path_mapping(
codex_home: &std::path::Path,
remote_plugin_id: &str,
plugin_path: &AbsolutePathBuf,
) -> std::io::Result<()> {
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
local_plugin_paths_by_remote_plugin_id.insert(
remote_plugin_id.to_string(),
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
);
let contents = serde_json::to_string_pretty(&json!({
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
}))
.map_err(std::io::Error::other)?;
std::fs::create_dir_all(codex_home.join(".tmp"))?;
std::fs::write(
codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
format!("{contents}\n"),
)
}
@@ -12,6 +12,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListResponse;
@@ -161,6 +162,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
enabled: true,
@@ -385,6 +387,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
enabled: true,
@@ -565,6 +568,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
plugin: PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
enabled: true,
@@ -655,6 +659,14 @@ fn expected_plugin_interface() -> PluginInterface {
}
}
fn expected_share_context(plugin_id: &str) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: plugin_id.to_string(),
creator_account_user_id: None,
creator_name: None,
}
}
fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf> {
let plugin_path = root.join(plugin_name);
write_file(
@@ -153,8 +153,14 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
}
#[tokio::test]
async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> {
async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = false
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -285,7 +291,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
let workspace_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-workspace/linear");
.join("plugins/cache/workspace-directory/linear");
std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?;
std::fs::write(
workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
@@ -363,7 +369,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<(
let remote_plugin_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-workspace/skill-improver");
.join("plugins/cache/workspace-directory/skill-improver");
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?;
std::fs::write(
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
@@ -299,7 +299,10 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
}
let plugin_list_request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let plugin_list_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
+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,
@@ -666,6 +666,7 @@ pub(super) async fn fetch_plugins_list(
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
marketplace_kinds: None,
},
})
.await
@@ -1439,6 +1439,7 @@ pub(super) fn plugins_test_summary(
PluginSummary {
id: id.to_string(),
name: name.to_string(),
share_context: None,
source: PluginSource::Local {
path: plugins_test_absolute_path(&format!("plugins/{name}")),
},