[codex] Add created-by-me remote plugin marketplace (#28203)

## Summary
- add the `created-by-me-remote` marketplace backed by paginated
`scope=USER` plugin directory and installed-plugin requests
- include USER plugins in installed-plugin caching, bundle sync, and
stale-cache cleanup without client-side discoverability filtering
- expose the marketplace through app-server v2 and regenerate the
protocol schemas

## Testing
- `cargo build -p codex-app-server --bin codex-app-server`
- production-auth `plugin/list` smoke test for `created-by-me-remote`
(returned the expected USER plugin as installed and enabled)
- `just test -p codex-core-plugins` (221 passed)
- `just test -p codex-app-server-protocol` (231 passed)
- `just test -p codex-app-server suite::v2::plugin_list::` (37 passed)
- `just fix -p codex-core-plugins -p codex-app-server-protocol -p
codex-app-server`
- `just fmt`
This commit is contained in:
Eric Ning
2026-06-15 15:07:07 -07:00
committed by GitHub
Unverified
parent 040dafa32d
commit 709f19e111
15 changed files with 431 additions and 17 deletions
+2 -1
View File
@@ -1845,7 +1845,8 @@
"local",
"vertical",
"workspace-directory",
"shared-with-me"
"shared-with-me",
"created-by-me-remote"
],
"type": "string"
},
@@ -13135,7 +13135,8 @@
"local",
"vertical",
"workspace-directory",
"shared-with-me"
"shared-with-me",
"created-by-me-remote"
],
"type": "string"
},
@@ -9608,7 +9608,8 @@
"local",
"vertical",
"workspace-directory",
"shared-with-me"
"shared-with-me",
"created-by-me-remote"
],
"type": "string"
},
@@ -10,7 +10,8 @@
"local",
"vertical",
"workspace-directory",
"shared-with-me"
"shared-with-me",
"created-by-me-remote"
],
"type": "string"
}
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me";
export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me" | "created-by-me-remote";
@@ -165,6 +165,9 @@ pub enum PluginListMarketplaceKind {
#[serde(rename = "shared-with-me")]
#[ts(rename = "shared-with-me")]
SharedWithMe,
#[serde(rename = "created-by-me-remote")]
#[ts(rename = "created-by-me-remote")]
CreatedByMeRemote,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2907,6 +2907,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
PluginListMarketplaceKind::Vertical,
PluginListMarketplaceKind::WorkspaceDirectory,
PluginListMarketplaceKind::SharedWithMe,
PluginListMarketplaceKind::CreatedByMeRemote,
]),
})
.unwrap(),
@@ -2917,6 +2918,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
"vertical",
"workspace-directory",
"shared-with-me",
"created-by-me-remote",
],
}),
);
+1 -1
View File
@@ -204,7 +204,7 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole;
use codex_config::types::McpServerConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginListBackgroundTaskOptions;
use codex_core_plugins::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
@@ -156,6 +157,7 @@ fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static
let mut marketplaces = Vec::new();
if config.features.enabled(Feature::RemotePlugin) {
marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME);
marketplaces.push(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME);
}
marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME);
if config.features.enabled(Feature::PluginSharing) {
@@ -552,6 +554,9 @@ impl PluginRequestProcessor {
let plugins_input = config.plugins_config_input();
let include_shared_with_me =
marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe);
let include_created_by_me_remote = marketplace_kinds
.contains(&PluginListMarketplaceKind::CreatedByMeRemote)
&& config.features.enabled(Feature::RemotePlugin);
let include_global_remote =
!explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin);
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -667,6 +672,9 @@ impl PluginRequestProcessor {
if include_global_remote {
remote_sources.push(RemoteMarketplaceSource::Global);
}
if include_created_by_me_remote {
remote_sources.push(RemoteMarketplaceSource::CreatedByMeRemote);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
@@ -717,7 +725,11 @@ impl PluginRequestProcessor {
}
}
}
if include_local || include_shared_with_me || include_global_remote {
if include_local
|| include_created_by_me_remote
|| include_shared_with_me
|| include_global_remote
{
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
@@ -35,6 +35,7 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
use wiremock::matchers::query_param_is_missing;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
@@ -229,6 +230,7 @@ enabled = true
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut app_server = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
@@ -1498,6 +1500,7 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let installed_path = codex_home
.path()
@@ -1569,6 +1572,7 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let old_path = codex_home
.path()
@@ -1894,6 +1898,7 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -1929,6 +1934,7 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
@@ -2006,6 +2012,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_requested()
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2093,6 +2100,7 @@ async fn plugin_list_propagates_explicit_openai_curated_remote_collection_errors
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2318,6 +2326,7 @@ plugin_sharing = true
let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true);
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2418,6 +2427,7 @@ plugin_sharing = false
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2456,6 +2466,97 @@ plugin_sharing = false
Ok(())
}
#[tokio::test]
async fn plugin_installed_includes_created_by_me_when_remote_plugins_enabled() -> 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
remote_plugin = true
plugin_sharing = false
"#,
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,
)?;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let bundle_url = mount_remote_plugin_bundle(
&server,
"private-linear",
remote_plugin_bundle_tar_gz_bytes("private-linear")?,
)
.await;
let mut user_installed_body: serde_json::Value =
serde_json::from_str(&user_remote_plugin_page_body(
"plugins~Plugin_55555555555555555555555555555555",
"private-linear",
"Private Linear",
"PRIVATE",
/*enabled*/ Some(true),
))?;
user_installed_body["plugins"][0]["release"]["bundle_download_url"] =
serde_json::json!(bundle_url);
mount_remote_installed_plugins(
&server,
"USER",
&serde_json::to_string(&user_installed_body)?,
)
.await;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_installed_request(PluginInstalledParams {
cwds: None,
install_suggestion_plugin_names: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginInstalledResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
assert_eq!(response.marketplaces[0].name, "created-by-me-remote");
assert_eq!(
response.marketplaces[0]
.plugins
.iter()
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![("private-linear@created-by-me-remote", true, true)]
);
wait_for_path_exists(
&codex_home.path().join(
"plugins/cache/created-by-me-remote/private-linear/1.2.3/.codex-plugin/plugin.json",
),
)
.await?;
wait_for_remote_installed_scope_request(&server, "USER").await?;
Ok(())
}
#[tokio::test]
async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -2493,6 +2594,7 @@ plugin_sharing = false
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
@@ -2567,6 +2669,7 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag
);
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2621,6 +2724,142 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag
Ok(())
}
#[tokio::test]
async fn plugin_list_fetches_user_plugins_in_created_by_me_remote_marketplace() -> 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
remote_plugin = true
plugin_sharing = false
"#,
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 private_page: serde_json::Value = serde_json::from_str(&user_remote_plugin_page_body(
"plugins~Plugin_55555555555555555555555555555555",
"private-linear",
"Private Linear",
"PRIVATE",
/*enabled*/ None,
))?;
private_page["pagination"]["next_page_token"] = serde_json::json!("page-2");
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "USER"))
.and(query_param("limit", "200"))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(private_page))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "USER"))
.and(query_param("limit", "200"))
.and(query_param("pageToken", "page-2"))
.respond_with(
ResponseTemplate::new(200).set_body_string(user_remote_plugin_page_body(
"plugins~Plugin_66666666666666666666666666666666",
"second-private-linear",
"Second Private Linear",
"PRIVATE",
/*enabled*/ None,
)),
)
.mount(&server)
.await;
mount_remote_installed_plugins(
&server,
"USER",
&user_remote_plugin_page_body(
"plugins~Plugin_55555555555555555555555555555555",
"private-linear",
"Private Linear",
"PRIVATE",
/*enabled*/ Some(true),
),
)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let mut mcp = TestAppServer::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::CreatedByMeRemote]),
})
.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, "created-by-me-remote");
assert_eq!(
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Created by me")
);
assert_eq!(marketplace.plugins.len(), 2);
assert_eq!(
marketplace.plugins[0].id,
"private-linear@created-by-me-remote"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_55555555555555555555555555555555")
);
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
assert_eq!(marketplace.plugins[0].share_context, None);
assert_eq!(
marketplace.plugins[1].id,
"second-private-linear@created-by-me-remote"
);
assert_eq!(marketplace.plugins[1].installed, false);
assert_eq!(marketplace.plugins[1].enabled, false);
assert!(
!server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.any(|request| {
request.url.path().ends_with("/ps/plugins/list")
&& request
.url
.query_pairs()
.any(|(key, value)| key == "scope" && value != "USER")
})
);
Ok(())
}
#[tokio::test]
async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -2684,6 +2923,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -2876,6 +3116,61 @@ plugin_sharing = false
Ok(())
}
#[tokio::test]
async fn plugin_list_omits_created_by_me_when_remote_plugins_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
remote_plugin = false
plugin_sharing = 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 mut mcp = TestAppServer::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::CreatedByMeRemote]),
})
.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,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
}
);
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()?;
@@ -3362,6 +3657,10 @@ async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body:
.await;
}
async fn mount_empty_user_installed_plugins(server: &MockServer) {
mount_remote_installed_plugins(server, "USER", empty_remote_installed_plugins_body()).await;
}
fn empty_remote_installed_plugins_body() -> &'static str {
r#"{
"plugins": [],
@@ -3428,6 +3727,23 @@ fn workspace_remote_plugin_page_body(
)
}
fn user_remote_plugin_page_body(
remote_plugin_id: &str,
plugin_name: &str,
display_name: &str,
discoverability: &str,
enabled: Option<bool>,
) -> String {
workspace_remote_plugin_page_body(
remote_plugin_id,
plugin_name,
display_name,
discoverability,
enabled,
)
.replacen(r#""scope": "WORKSPACE""#, r#""scope": "USER""#, 1)
}
fn remote_installed_plugin_body(
bundle_download_url: &str,
release_version: &str,
@@ -268,6 +268,7 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
for (scope, body) in [
("GLOBAL", global_installed_body),
("USER", empty_page_body),
("WORKSPACE", empty_page_body),
] {
Mock::given(method("GET"))
@@ -628,7 +628,7 @@ remote_plugin = true
.await
.expect("remote plugin catalog cache should write");
for scope in ["GLOBAL", "WORKSPACE"] {
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
+39 -4
View File
@@ -51,6 +51,7 @@ pub use share::save_remote_plugin_share;
pub use share::update_remote_plugin_share_targets;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote";
pub const REMOTE_CREATED_BY_ME_MARKETPLACE_NAME: &str = "created-by-me-remote";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str =
@@ -58,6 +59,7 @@ pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str =
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str =
"workspace-shared-with-me-unlisted";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote";
pub const REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME: &str = "Created by me";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str =
@@ -71,11 +73,15 @@ const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 6] = [
(
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME,
REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
@@ -109,6 +115,7 @@ pub struct RemoteMarketplace {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteMarketplaceSource {
Global,
CreatedByMeRemote,
WorkspaceDirectory,
SharedWithMe,
}
@@ -338,6 +345,8 @@ pub enum RemotePluginCatalogError {
pub enum RemotePluginScope {
#[serde(rename = "GLOBAL")]
Global,
#[serde(rename = "USER")]
User,
#[serde(rename = "WORKSPACE")]
Workspace,
}
@@ -346,6 +355,7 @@ impl RemotePluginScope {
fn api_value(self) -> &'static str {
match self {
Self::Global => "GLOBAL",
Self::User => "USER",
Self::Workspace => "WORKSPACE",
}
}
@@ -353,6 +363,7 @@ impl RemotePluginScope {
fn marketplace_name(self) -> &'static str {
match self {
Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME,
Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_NAME,
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME,
}
}
@@ -360,6 +371,7 @@ impl RemotePluginScope {
fn marketplace_display_name(self) -> &'static str {
match self {
Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME,
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
}
}
@@ -367,6 +379,7 @@ impl RemotePluginScope {
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME => Some(Self::User),
REMOTE_WORKSPACE_MARKETPLACE_NAME
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME
| REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME
@@ -500,6 +513,7 @@ fn remote_plugin_canonical_marketplace_name(
) -> Result<&'static str, RemotePluginCatalogError> {
match plugin.scope {
RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME),
RemotePluginScope::User => Ok(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME),
RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? {
RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME),
RemotePluginShareDiscoverability::Private
@@ -631,6 +645,22 @@ pub async fn fetch_remote_marketplaces(
);
}
}
RemoteMarketplaceSource::CreatedByMeRemote => {
let scope = RemotePluginScope::User;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
if let Some(marketplace) = build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
directory_plugins,
installed_plugins,
/*include_installed_only*/ false,
)? {
marketplaces.push(marketplace);
}
}
RemoteMarketplaceSource::WorkspaceDirectory => {
let scope = RemotePluginScope::Workspace;
let directory_plugins =
@@ -838,9 +868,14 @@ pub(crate) async fn fetch_remote_installed_plugins(
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let user = async {
let scope = RemotePluginScope::User;
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
let mut installed_plugins = [global, workspace]
let (global, workspace, user) = tokio::try_join!(global, workspace, user)?;
let mut installed_plugins = [global, workspace, user]
.into_iter()
.flat_map(|(_scope, plugins)| plugins)
.map(|plugin| remote_installed_plugin_to_cache_entry(&plugin))
@@ -1266,7 +1301,7 @@ fn remote_plugin_share_context(
plugin: &RemotePluginDirectoryItem,
) -> Result<Option<RemotePluginShareContext>, RemotePluginCatalogError> {
match plugin.scope {
RemotePluginScope::Global => Ok(None),
RemotePluginScope::Global | RemotePluginScope::User => Ok(None),
RemotePluginScope::Workspace => {
let discoverability = workspace_plugin_discoverability(plugin)?;
Ok(Some(RemotePluginShareContext {
@@ -1,3 +1,4 @@
use super::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME;
use super::REMOTE_GLOBAL_MARKETPLACE_NAME;
use super::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use super::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
@@ -144,12 +145,24 @@ pub async fn sync_remote_installed_plugin_bundles_once(
.await?;
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let user = async {
let scope = RemotePluginScope::User;
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
config, auth, scope, /*include_download_urls*/ true,
)
.await?;
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
let (global, workspace, user) = tokio::try_join!(global, workspace, user)?;
let store = PluginStore::try_new(codex_home.clone())?;
let mut installed_plugin_names_by_marketplace =
BTreeMap::<String, BTreeSet<String>>::from_iter([
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
(
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
@@ -170,7 +183,7 @@ pub async fn sync_remote_installed_plugin_bundles_once(
let mut installed_plugin_ids = BTreeSet::new();
let mut failed_remote_plugin_ids = BTreeSet::new();
for (_scope, installed_plugins) in [global, workspace] {
for (_scope, installed_plugins) in [global, workspace, user] {
for installed_plugin in installed_plugins {
let plugin = installed_plugin.plugin;
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
@@ -308,6 +321,7 @@ fn remove_stale_remote_plugin_caches(
let mut removed_cache_plugin_ids = Vec::new();
for marketplace_name in [
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
@@ -517,8 +531,27 @@ mod tests {
}
#[test]
fn stale_remote_plugin_cleanup_removes_old_shared_with_me_cache_and_keeps_canonical_cache() {
fn stale_remote_plugin_cleanup_removes_stale_marketplace_caches_and_keeps_canonical_cache() {
let codex_home = tempfile::tempdir().expect("create codex home");
let created_by_me_cached_manifest = codex_home
.path()
.join(PLUGINS_CACHE_DIR)
.join(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME)
.join("created-by-me-plugin")
.join("1.2.3")
.join(".codex-plugin")
.join("plugin.json");
std::fs::create_dir_all(
created_by_me_cached_manifest
.parent()
.expect("manifest parent"),
)
.expect("create cached plugin manifest parent");
std::fs::write(
&created_by_me_cached_manifest,
r#"{"name":"created-by-me-plugin"}"#,
)
.expect("write cached plugin manifest");
let cached_manifest = codex_home
.path()
.join(PLUGINS_CACHE_DIR)
@@ -546,6 +579,10 @@ mod tests {
let installed_plugin_names_by_marketplace =
BTreeMap::<String, BTreeSet<String>>::from_iter([
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
(
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
@@ -572,8 +609,12 @@ mod tests {
assert_eq!(
removed,
vec!["private-plugin@workspace-shared-with-me-private".to_string()]
vec![
"created-by-me-plugin@created-by-me-remote".to_string(),
"private-plugin@workspace-shared-with-me-private".to_string(),
]
);
assert!(!created_by_me_cached_manifest.exists());
assert!(!cached_manifest.exists());
assert!(canonical_cached_manifest.is_file());
}
@@ -244,7 +244,7 @@ remote_plugin = true
.all(|plugin| plugin.id != "github@openai-curated-remote")
);
for scope in ["GLOBAL", "WORKSPACE"] {
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))