mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add remote plugin uninstall API (#19456)
## Summary - Adds the remote `plugin/uninstall` request form using required `pluginId` plus optional `remoteMarketplaceName`, while preserving local `pluginId` uninstall. - Adds `codex_core_plugins::remote::uninstall_remote_plugin` for the deployed ChatGPT plugin backend uninstall path and validates the backend returns the same id with `enabled: false`. - Routes app-server remote uninstall through feature checks, remote plugin id validation, backend mutation, local downloaded cache deletion, cache clearing, docs, and regenerated protocol schemas. ## Tests - `just write-app-server-schema` - `just fmt` - `cargo test -p codex-app-server-protocol plugin_uninstall_params_serialization_omits_force_remote_sync` - `cargo test -p codex-app-server plugin_uninstall --test all` - `cargo test -p codex-app-server plugin_uninstall` - `cargo build -p codex-cli` - `CODEX_BIN=/Users/xli/code/codex/codex-rs/target/debug/codex python3 /Users/xli/.codex/skills/xli-test-marketplace-api/scripts/run_marketplace_api_matrix.py` (44 pass / 0 fail) - `just fix -p codex-app-server-protocol -p codex-app-server -p codex-tui` - `just fix -p codex-app-server`
This commit is contained in:
committed by
GitHub
Unverified
parent
7d72fc8f53
commit
803705f795
@@ -10305,6 +10305,27 @@ mod tests {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginUninstallParams {
|
||||
plugin_id: "plugins~Plugin_gmail".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"pluginId": "plugins~Plugin_gmail",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginUninstallParams>(json!({
|
||||
"pluginId": "plugins~Plugin_gmail",
|
||||
"forceRemoteSync": true,
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginUninstallParams {
|
||||
plugin_id: "plugins~Plugin_gmail".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -207,7 +207,7 @@ Example with notification opt-out:
|
||||
- `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API.
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
|
||||
@@ -407,11 +407,7 @@ impl CodexMessageProcessor {
|
||||
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
if plugin_name.is_empty()
|
||||
|| !plugin_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
{
|
||||
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
|
||||
));
|
||||
@@ -579,6 +575,16 @@ impl CodexMessageProcessor {
|
||||
params: PluginUninstallParams,
|
||||
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
|
||||
let PluginUninstallParams { plugin_id } = params;
|
||||
if codex_core::plugins::PluginId::parse(&plugin_id).is_err()
|
||||
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `app_`, `asdk_app_`, or `connector_`",
|
||||
));
|
||||
}
|
||||
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
|
||||
return self.remote_plugin_uninstall_response(plugin_id).await;
|
||||
}
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
|
||||
plugins_manager
|
||||
@@ -648,6 +654,54 @@ impl CodexMessageProcessor {
|
||||
MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_plugin_uninstall_response(
|
||||
&self,
|
||||
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)
|
||||
{
|
||||
return Err(invalid_request("remote plugin uninstall is not enabled"));
|
||||
}
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
|
||||
));
|
||||
}
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
codex_core_plugins::remote::uninstall_remote_plugin(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.to_path_buf(),
|
||||
&plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin"))?;
|
||||
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(PluginUninstallResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
|
||||
plugin_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
}
|
||||
|
||||
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
|
||||
!plugin_name.is_empty()
|
||||
&& is_valid_remote_plugin_id(plugin_name)
|
||||
&& (plugin_name.starts_with("plugins~")
|
||||
|| plugin_name.starts_with("app_")
|
||||
|| plugin_name.starts_with("asdk_app_")
|
||||
|| plugin_name.starts_with("connector_"))
|
||||
}
|
||||
|
||||
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
|
||||
@@ -734,7 +788,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
| RemotePluginCatalogError::UnexpectedStatus { .. }
|
||||
| RemotePluginCatalogError::Decode { .. }
|
||||
| RemotePluginCatalogError::UnexpectedPluginId { .. }
|
||||
| RemotePluginCatalogError::UnexpectedEnabledState { .. } => JSONRPCErrorError {
|
||||
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
|
||||
| RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("{context}: {err}"),
|
||||
data: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::McpProcess;
|
||||
@@ -16,8 +17,15 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear";
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> {
|
||||
@@ -143,6 +151,340 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "plugins~Plugin_sample".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("remote plugin uninstall is not enabled")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> 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,
|
||||
)?;
|
||||
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "GLOBAL").await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear");
|
||||
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"),
|
||||
r#"{"name":"linear","version":"1.0.0"}"#,
|
||||
)?;
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
|
||||
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: REMOTE_PLUGIN_ID.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
assert!(!remote_plugin_cache_root.exists());
|
||||
assert!(!legacy_remote_plugin_cache_root.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> 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,
|
||||
)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let workspace_cache_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-workspace/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"),
|
||||
r#"{"name":"linear","version":"1.0.0"}"#,
|
||||
)?;
|
||||
let global_cache_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear");
|
||||
std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: REMOTE_PLUGIN_ID.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
assert!(!workspace_cache_root.exists());
|
||||
assert!(global_cache_root.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_posts_even_when_remote_detail_fetch_fails() -> 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,
|
||||
)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
|
||||
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: REMOTE_PLUGIN_ID.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
assert!(!legacy_remote_plugin_cache_root.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid plugin id"));
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
"/plugins/sample-plugin/uninstall",
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "linear/../../oops".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid plugin id"));
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
"/plugins/linear/../../oops/uninstall",
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: String::new(),
|
||||
})
|
||||
.await?;
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid plugin id"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_installed_plugin(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
@@ -161,3 +503,109 @@ fn write_installed_plugin(
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_remote_plugin_catalog_config(
|
||||
codex_home: &std::path::Path,
|
||||
base_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{base_url}"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
scope: &str,
|
||||
) {
|
||||
let detail_body = format!(
|
||||
r#"{{
|
||||
"id": "{remote_plugin_id}",
|
||||
"name": "linear",
|
||||
"scope": "{scope}",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"version": "{release_version}",
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {{
|
||||
"short_description": "Plan and track work"
|
||||
}},
|
||||
"skills": []
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
|
||||
.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;
|
||||
}
|
||||
|
||||
async fn mount_empty_remote_installed_plugins(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#,
|
||||
))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_for_remote_plugin_request_count(
|
||||
server: &MockServer,
|
||||
method_name: &str,
|
||||
path_suffix: &str,
|
||||
expected_count: usize,
|
||||
) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = server.received_requests().await else {
|
||||
if expected_count == 0 {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
bail!("wiremock did not record requests");
|
||||
};
|
||||
let request_count = requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.method == method_name && request.url.path().ends_with(path_suffix)
|
||||
})
|
||||
.count();
|
||||
if request_count == expected_count {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
if request_count > expected_count {
|
||||
bail!(
|
||||
"expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}"
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::PluginId;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
|
||||
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";
|
||||
@@ -111,18 +118,21 @@ pub enum RemotePluginCatalogError {
|
||||
},
|
||||
|
||||
#[error(
|
||||
"remote plugin install returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin install returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
UnexpectedEnabledState {
|
||||
plugin_id: String,
|
||||
expected_enabled: bool,
|
||||
actual_enabled: bool,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
CacheRemove(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
@@ -256,7 +266,7 @@ struct RemotePluginInstalledResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginInstallResponse {
|
||||
struct RemotePluginMutationResponse {
|
||||
id: String,
|
||||
enabled: bool,
|
||||
}
|
||||
@@ -414,6 +424,46 @@ async fn fetch_remote_plugin_detail_with_download_url_option(
|
||||
});
|
||||
}
|
||||
|
||||
build_remote_plugin_detail(
|
||||
config,
|
||||
auth,
|
||||
scope,
|
||||
marketplace_name.to_string(),
|
||||
plugin_id,
|
||||
plugin,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_remote_plugin_detail_by_id(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
plugin_id: &str,
|
||||
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
||||
let plugin = fetch_plugin_detail(
|
||||
config, auth, plugin_id, /*include_download_urls*/ false,
|
||||
)
|
||||
.await?;
|
||||
let scope = plugin.scope;
|
||||
build_remote_plugin_detail(
|
||||
config,
|
||||
auth,
|
||||
scope,
|
||||
scope.marketplace_name().to_string(),
|
||||
plugin_id,
|
||||
plugin,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_remote_plugin_detail(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
marketplace_name: String,
|
||||
plugin_id: &str,
|
||||
plugin: RemotePluginDirectoryItem,
|
||||
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
||||
let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -445,7 +495,7 @@ async fn fetch_remote_plugin_detail_with_download_url_option(
|
||||
.collect();
|
||||
|
||||
Ok(RemotePluginDetail {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
marketplace_name,
|
||||
marketplace_display_name: scope.marketplace_display_name().to_string(),
|
||||
summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()),
|
||||
description: non_empty_string(Some(&plugin.release.description)),
|
||||
@@ -473,7 +523,7 @@ pub async fn install_remote_plugin(
|
||||
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginInstallResponse = send_and_decode(request, &url).await?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
if response.id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
@@ -491,6 +541,121 @@ pub async fn install_remote_plugin(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall_remote_plugin(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: PathBuf,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
if response.id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.id,
|
||||
});
|
||||
}
|
||||
if response.enabled {
|
||||
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled: false,
|
||||
actual_enabled: response.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await {
|
||||
Ok(remote_detail) => Some(remote_detail),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_id,
|
||||
"failed to read remote plugin details after uninstall; skipping named cache removal: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let legacy_plugin_id = plugin_id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
remove_remote_plugin_cache(codex_home, remote_detail, legacy_plugin_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
RemotePluginCatalogError::CacheRemove(format!(
|
||||
"failed to join remote plugin cache removal task: {err}"
|
||||
))
|
||||
})?
|
||||
.map_err(RemotePluginCatalogError::CacheRemove)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_remote_plugin_cache(
|
||||
codex_home: PathBuf,
|
||||
remote_detail: Option<RemotePluginDetail>,
|
||||
legacy_plugin_id: String,
|
||||
) -> Result<(), String> {
|
||||
if let Some(remote_detail) = remote_detail {
|
||||
let marketplace_name = remote_detail.marketplace_name;
|
||||
let plugin_name = remote_detail.summary.name;
|
||||
let store = PluginStore::try_new(codex_home.clone())
|
||||
.map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?;
|
||||
let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(
|
||||
|err| {
|
||||
format!(
|
||||
"invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}"
|
||||
)
|
||||
},
|
||||
)?;
|
||||
let plugin_cache_root = store.plugin_base_root(&plugin_id);
|
||||
store.uninstall(&plugin_id).map_err(|err| {
|
||||
format!(
|
||||
"failed to remove remote plugin cache entry {}: {err}",
|
||||
plugin_cache_root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(marketplace_name)
|
||||
.join(legacy_plugin_id);
|
||||
if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() {
|
||||
remove_path_if_exists(&legacy_remote_plugin_cache_root)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for scope in RemotePluginScope::all() {
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(scope.marketplace_name())
|
||||
.join(&legacy_plugin_id);
|
||||
remove_path_if_exists(&legacy_remote_plugin_cache_root)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_path_if_exists(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
result.map_err(|err| {
|
||||
format!(
|
||||
"failed to remove remote plugin cache entry {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_remote_plugin_summary(
|
||||
plugin: &RemotePluginDirectoryItem,
|
||||
installed_plugin: Option<&RemotePluginInstalledItem>,
|
||||
|
||||
Reference in New Issue
Block a user