mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
040dafa32d
commit
709f19e111
+2
-1
@@ -1845,7 +1845,8 @@
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
"shared-with-me",
|
||||
"created-by-me-remote"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+2
-1
@@ -13135,7 +13135,8 @@
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
"shared-with-me",
|
||||
"created-by-me-remote"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user