diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d96b0e514a..c95e7e4b17 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. +- `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. - `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 db8ade8c67..440f660a6f 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -4,6 +4,61 @@ use crate::error_code::invalid_request; use codex_app_server_protocol::PluginInstallPolicy; impl CodexMessageProcessor { + pub(crate) async fn sync_remote_plugin_enabled_config_write( + &self, + 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")); + } + 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(), + }; + let remote_marketplaces = codex_core_plugins::remote::fetch_remote_marketplaces( + &remote_plugin_service_config, + auth.as_ref(), + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "find remote plugin"))?; + + 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"))) + } + pub(super) async fn plugin_list( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index dbe0132247..702d4a2121 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -38,8 +39,10 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::DeviceKeyCreateParams; use codex_app_server_protocol::DeviceKeyPublicParams; use codex_app_server_protocol::DeviceKeySignParams; @@ -84,6 +87,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use codex_state::log_db::LogDbLayer; use futures::FutureExt; +use serde_json::Value as JsonValue; use tokio::sync::broadcast; use tokio::sync::watch; use tokio::time::Duration; @@ -823,10 +827,12 @@ impl MessageProcessor { } ClientRequest::ConfigValueWrite { request_id, params } => { self.handle_config_value_write(request_id_for_connection(request_id), params) + .boxed() .await; } ClientRequest::ConfigBatchWrite { request_id, params } => { self.handle_config_batch_write(request_id_for_connection(request_id), params) + .boxed() .await; } ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { @@ -989,7 +995,9 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigValueWriteParams, ) { - let result = self.config_api.write_value(params).await; + let result = self + .write_config_value_with_remote_plugin_sync(params) + .await; self.handle_config_mutation_result(request_id, result).await } @@ -998,10 +1006,122 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - let result = self.config_api.batch_write(params).await; + let result = self + .batch_write_config_with_remote_plugin_sync(params) + .await; self.handle_config_mutation_result(request_id, result).await; } + async fn write_config_value_with_remote_plugin_sync( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let ConfigValueWriteParams { + key_path, + value, + merge_strategy, + file_path, + expected_version, + } = params; + + if let Some((plugin_id, enabled)) = remote_plugin_enabled_config_edit(&key_path, &value) { + let response = self + .config_api + .batch_write(ConfigBatchWriteParams { + edits: Vec::new(), + file_path, + expected_version, + reload_user_config: false, + }) + .await?; + self.codex_message_processor + .sync_remote_plugin_enabled_config_write(plugin_id, enabled) + .await?; + return Ok(response); + } + + self.config_api + .write_value(ConfigValueWriteParams { + key_path, + value, + merge_strategy, + file_path, + expected_version, + }) + .await + } + + async fn batch_write_config_with_remote_plugin_sync( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let ConfigBatchWriteParams { + edits, + file_path, + expected_version, + reload_user_config, + } = params; + let mut local_edits = Vec::::new(); + let mut remote_plugin_toggles = BTreeMap::::new(); + + for edit in edits { + if let Some((plugin_id, enabled)) = + remote_plugin_enabled_config_edit(&edit.key_path, &edit.value) + { + remote_plugin_toggles.insert(plugin_id, enabled); + } else { + local_edits.push(edit); + } + } + + if !remote_plugin_toggles.is_empty() && !local_edits.is_empty() { + return Err(invalid_request( + "remote plugin enablement edits cannot be batched with local config edits", + )); + } + + if remote_plugin_toggles.is_empty() { + return self + .config_api + .batch_write(ConfigBatchWriteParams { + edits: local_edits, + file_path, + expected_version, + reload_user_config, + }) + .await; + } + + let response = self + .config_api + .batch_write(ConfigBatchWriteParams { + edits: Vec::new(), + file_path: file_path.clone(), + expected_version, + reload_user_config: false, + }) + .await?; + + for (plugin_id, enabled) in remote_plugin_toggles { + self.codex_message_processor + .sync_remote_plugin_enabled_config_write(plugin_id, enabled) + .await?; + } + + if reload_user_config { + self.config_api + .batch_write(ConfigBatchWriteParams { + edits: Vec::new(), + file_path, + expected_version: None, + reload_user_config: true, + }) + .await?; + } + + Ok(response) + } + async fn handle_experimental_feature_enablement_set( &self, request_id: ConnectionRequestId, @@ -1284,6 +1404,33 @@ fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationIte }) } +fn remote_plugin_enabled_config_edit(key_path: &str, value: &JsonValue) -> Option<(String, bool)> { + let enabled = value.as_bool()?; + let mut segments = key_path.split('.'); + let table = segments.next()?; + let plugin_id = segments.next()?; + let field = segments.next()?; + if table == "plugins" + && field == "enabled" + && segments.next().is_none() + && is_remote_plugin_config_id(plugin_id) + { + return Some((plugin_id.to_string(), enabled)); + } + None +} + +fn is_remote_plugin_config_id(plugin_id: &str) -> bool { + !plugin_id.is_empty() + && plugin_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') + && (plugin_id.starts_with("plugins~") + || plugin_id.starts_with("app_") + || plugin_id.starts_with("asdk_app_") + || plugin_id.starts_with("connector_")) +} + #[cfg(test)] mod tracing_tests; 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 a115c83077..952c9a37df 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -21,11 +21,16 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WriteStatus; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use flate2::Compression; @@ -253,6 +258,119 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> Ok(()) } +#[tokio::test] +async fn config_batch_write_enables_remote_plugin_via_remote_install_and_cache() -> 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, + installed_path.join(".codex-plugin/plugin.json"), + ) + .await; + + let mut mcp = McpProcess::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 = + send_remote_plugin_enabled_config_write(&mut mcp, REMOTE_PLUGIN_ID, /*enabled*/ true) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ConfigWriteResponse = to_response(response)?; + + assert_eq!(response.status, WriteStatus::Ok); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 1, + ) + .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() +-> 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"); + std::fs::create_dir_all(installed_path.join(".codex-plugin"))?; + std::fs::write( + installed_path.join(".codex-plugin/plugin.json"), + 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; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = + send_remote_plugin_enabled_config_write(&mut mcp, REMOTE_PLUGIN_ID, /*enabled*/ false) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ConfigWriteResponse = to_response(response)?; + + assert_eq!(response.status, WriteStatus::Ok); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + assert_config_does_not_contain_remote_plugin_id(codex_home.path(), REMOTE_PLUGIN_ID)?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { let codex_home = TempDir::new()?; @@ -1261,6 +1379,117 @@ async fn mount_empty_remote_installed_plugins(server: &MockServer) { .await; } +async fn mount_remote_plugin_marketplaces_for_toggle( + 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")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body)) + .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) { Mock::given(method("POST")) .and(path(format!( @@ -1276,6 +1505,40 @@ 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, @@ -1319,6 +1582,36 @@ async fn send_remote_plugin_install_request( .await } +async fn send_remote_plugin_enabled_config_write( + mcp: &mut McpProcess, + remote_plugin_id: &str, + enabled: bool, +) -> Result { + mcp.send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: format!("plugins.{remote_plugin_id}.enabled"), + value: json!(enabled), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await +} + +fn assert_config_does_not_contain_remote_plugin_id( + codex_home: &std::path::Path, + remote_plugin_id: &str, +) -> Result<()> { + let config = std::fs::read_to_string(codex_home.join("config.toml"))?; + assert!( + !config.contains(remote_plugin_id), + "remote plugin id should not be written to config.toml" + ); + Ok(()) +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str,