diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c95e7e4b17..e51da297e2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -222,7 +222,7 @@ Example with notification opt-out: - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk. -- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. For remote ChatGPT plugin IDs, `plugins..enabled` is a compatibility write that is translated into remote install/uninstall instead of being persisted to config.toml. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. For remote ChatGPT plugin IDs, `plugins..enabled` is a compatibility write that is translated into remote enable/disable instead of being persisted to config.toml. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 440f660a6f..f749c69b7e 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -9,28 +9,13 @@ impl CodexMessageProcessor { plugin_id: String, enabled: bool, ) -> Result<(), JSONRPCErrorError> { - if enabled { - let remote_marketplace_name = - self.remote_marketplace_name_for_plugin(&plugin_id).await?; - self.remote_plugin_install_response(remote_marketplace_name, plugin_id) - .await?; - } else { - self.remote_plugin_uninstall_response(plugin_id).await?; - } - Ok(()) - } - - async fn remote_marketplace_name_for_plugin( - &self, - plugin_id: &str, - ) -> Result { 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 enable is not enabled")); + return Err(invalid_request("remote plugin enablement is not enabled")); } - if plugin_id.is_empty() || !is_valid_remote_plugin_id(plugin_id) { + 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", )); @@ -40,23 +25,24 @@ impl CodexMessageProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - let remote_marketplaces = codex_core_plugins::remote::fetch_remote_marketplaces( + codex_core_plugins::remote::set_remote_plugin_enabled( &remote_plugin_service_config, auth.as_ref(), + &plugin_id, + enabled, ) .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "find remote plugin"))?; + .map_err(|err| { + let context = if enabled { + "enable remote plugin" + } else { + "disable remote plugin" + }; + remote_plugin_catalog_error_to_jsonrpc(err, context) + })?; - remote_marketplaces - .into_iter() - .find_map(|marketplace| { - marketplace - .plugins - .iter() - .any(|plugin| plugin.id == plugin_id) - .then_some(marketplace.name) - }) - .ok_or_else(|| invalid_request(format!("remote plugin `{plugin_id}` was not found"))) + self.clear_plugin_related_caches(); + Ok(()) } pub(super) async fn plugin_list( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 952c9a37df..2d1175b366 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -259,33 +259,22 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> } #[tokio::test] -async fn config_batch_write_enables_remote_plugin_via_remote_install_and_cache() -> Result<()> { +async fn config_batch_write_enables_remote_plugin_via_remote_enable_without_cache_install() +-> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; let installed_path = codex_home .path() .join("plugins/cache/chatgpt-global/linear/1.2.3"); - let bundle_url = mount_remote_plugin_bundle( - &server, - /*status_code*/ 200, - remote_plugin_bundle_tar_gz_bytes("linear")?, - ) - .await; - configure_remote_plugin_test(codex_home.path(), &server)?; - mount_remote_plugin_marketplaces_for_toggle(&server, REMOTE_PLUGIN_ID, /*enabled*/ false).await; - mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; - mount_remote_plugin_install_after_cache_write( - &server, - REMOTE_PLUGIN_ID, + std::fs::create_dir_all(installed_path.join(".codex-plugin"))?; + std::fs::write( installed_path.join(".codex-plugin/plugin.json"), - ) - .await; + r#"{"name":"linear"}"#, + )?; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ true).await; - let mut mcp = McpProcess::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = @@ -302,17 +291,24 @@ async fn config_batch_write_enables_remote_plugin_via_remote_install_and_cache() wait_for_remote_plugin_request_count( &server, "POST", - &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + &format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/enable"), /*expected_count*/ 1, ) .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); assert_config_does_not_contain_remote_plugin_id(codex_home.path(), REMOTE_PLUGIN_ID)?; Ok(()) } #[tokio::test] -async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cache_removal() +async fn config_batch_write_disables_remote_plugin_via_remote_disable_without_cache_removal() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; @@ -325,20 +321,7 @@ async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cach r#"{"name":"linear"}"#, )?; configure_remote_plugin_test(codex_home.path(), &server)?; - 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; - mount_remote_plugin_detail_without_download_urls(&server, REMOTE_PLUGIN_ID, "1.2.3").await; - mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ false).await; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -357,16 +340,18 @@ async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cach wait_for_remote_plugin_request_count( &server, "POST", - &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + &format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/disable"), /*expected_count*/ 1, ) .await?; - assert!( - !codex_home - .path() - .join("plugins/cache/chatgpt-global/linear") - .exists() - ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 0, + ) + .await?; + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); assert_config_does_not_contain_remote_plugin_id(codex_home.path(), REMOTE_PLUGIN_ID)?; Ok(()) } @@ -1379,115 +1364,23 @@ async fn mount_empty_remote_installed_plugins(server: &MockServer) { .await; } -async fn mount_remote_plugin_marketplaces_for_toggle( +async fn mount_remote_plugin_enablement( server: &MockServer, remote_plugin_id: &str, enabled: bool, ) { - let global_directory_body = format!( - r#"{{ - "plugins": [ - {{ - "id": "{remote_plugin_id}", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": {{ - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": {{ - "short_description": "Plan and track work" - }}, - "skills": [] - }} - }} - ], - "pagination": {{ - "limit": 50, - "next_page_token": null - }} -}}"# - ); - let global_installed_body = if enabled { - format!( - r#"{{ - "plugins": [ - {{ - "id": "{remote_plugin_id}", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": {{ - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": {{ - "short_description": "Plan and track work" - }}, - "skills": [] - }}, - "enabled": true, - "disabled_skill_names": [] - }} - ], - "pagination": {{ - "limit": 50, - "next_page_token": null - }} -}}"# - ) - } else { - empty_remote_plugin_page_body() - }; - - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/list")) - .and(query_param("scope", "GLOBAL")) - .and(query_param("limit", "200")) + let action = if enabled { "enable" } else { "disable" }; + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{remote_plugin_id}/{action}" + ))) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body)) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"id":"{remote_plugin_id}","enabled":{enabled}}}"# + ))) .mount(server) .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/list")) - .and(query_param("scope", "WORKSPACE")) - .and(query_param("limit", "200")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_remote_plugin_page_body())) - .mount(server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", "GLOBAL")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body)) - .mount(server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", "WORKSPACE")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_remote_plugin_page_body())) - .mount(server) - .await; -} - -fn empty_remote_plugin_page_body() -> String { - r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"# - .to_string() } async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) { @@ -1505,40 +1398,6 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str .await; } -async fn mount_remote_plugin_detail_without_download_urls( - server: &MockServer, - remote_plugin_id: &str, - release_version: &str, -) { - let detail_body = format!( - r#"{{ - "id": "{remote_plugin_id}", - "name": "linear", - "scope": "GLOBAL", - "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; -} - #[derive(Debug, Clone)] struct CacheManifestExists { manifest_path: std::path::PathBuf, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 6c87f4dc30..753bc86f52 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -523,20 +523,25 @@ 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: 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: true, - actual_enabled: response.enabled, - }); - } + send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ true).await?; + + Ok(()) +} + +pub async fn set_remote_plugin_enabled( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, + enabled: bool, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + + let action = if enabled { "enable" } else { "disable" }; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/{plugin_id}/{action}"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?; + send_remote_plugin_mutation(request, &url, plugin_id, enabled).await?; Ok(()) } @@ -553,20 +558,7 @@ pub async fn uninstall_remote_plugin( 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, - }); - } + send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ false).await?; let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await { Ok(remote_detail) => Some(remote_detail), @@ -593,6 +585,30 @@ pub async fn uninstall_remote_plugin( Ok(()) } +async fn send_remote_plugin_mutation( + request: RequestBuilder, + url: &str, + plugin_id: &str, + expected_enabled: bool, +) -> Result<(), RemotePluginCatalogError> { + 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 != expected_enabled { + return Err(RemotePluginCatalogError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled, + actual_enabled: response.enabled, + }); + } + + Ok(()) +} + fn remove_remote_plugin_cache( codex_home: PathBuf, remote_detail: Option,