mirror of
https://github.com/openai/codex.git
synced 2026-05-16 01:02:48 +00:00
Compare commits
8 Commits
pr20460
...
xli-codex/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5a9651e3e | ||
|
|
133f5b371c | ||
|
|
0af813a408 | ||
|
|
bdcf098809 | ||
|
|
559cfce5a5 | ||
|
|
656f6f5a39 | ||
|
|
010064afba | ||
|
|
d01cf95bba |
@@ -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/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).
|
- `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/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.<remotePluginId>.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`.
|
- `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
|
### Example: Start or resume a thread
|
||||||
|
|||||||
@@ -4,6 +4,47 @@ use crate::error_code::invalid_request;
|
|||||||
use codex_app_server_protocol::PluginInstallPolicy;
|
use codex_app_server_protocol::PluginInstallPolicy;
|
||||||
|
|
||||||
impl CodexMessageProcessor {
|
impl CodexMessageProcessor {
|
||||||
|
pub(crate) async fn remote_plugin_enable_or_disable(
|
||||||
|
&self,
|
||||||
|
plugin_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), 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 enablement 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::set_remote_plugin_enabled(
|
||||||
|
&remote_plugin_service_config,
|
||||||
|
auth.as_ref(),
|
||||||
|
&plugin_id,
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
let context = if enabled {
|
||||||
|
"enable remote plugin"
|
||||||
|
} else {
|
||||||
|
"disable remote plugin"
|
||||||
|
};
|
||||||
|
remote_plugin_catalog_error_to_jsonrpc(err, context)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.clear_plugin_related_caches();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn plugin_list(
|
pub(super) async fn plugin_list(
|
||||||
&self,
|
&self,
|
||||||
request_id: ConnectionRequestId,
|
request_id: ConnectionRequestId,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::config_api::ConfigApi;
|
|||||||
use crate::config_manager::ConfigManager;
|
use crate::config_manager::ConfigManager;
|
||||||
use crate::connection_rpc_gate::ConnectionRpcGate;
|
use crate::connection_rpc_gate::ConnectionRpcGate;
|
||||||
use crate::device_key_api::DeviceKeyApi;
|
use crate::device_key_api::DeviceKeyApi;
|
||||||
|
use crate::error_code::internal_error;
|
||||||
use crate::error_code::invalid_request;
|
use crate::error_code::invalid_request;
|
||||||
use crate::external_agent_config_api::ExternalAgentConfigApi;
|
use crate::external_agent_config_api::ExternalAgentConfigApi;
|
||||||
use crate::fs_api::FsApi;
|
use crate::fs_api::FsApi;
|
||||||
@@ -40,6 +41,7 @@ use codex_app_server_protocol::ClientRequest;
|
|||||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||||
use codex_app_server_protocol::ConfigWarningNotification;
|
use codex_app_server_protocol::ConfigWarningNotification;
|
||||||
|
use codex_app_server_protocol::ConfigWriteResponse;
|
||||||
use codex_app_server_protocol::DeviceKeyCreateParams;
|
use codex_app_server_protocol::DeviceKeyCreateParams;
|
||||||
use codex_app_server_protocol::DeviceKeyPublicParams;
|
use codex_app_server_protocol::DeviceKeyPublicParams;
|
||||||
use codex_app_server_protocol::DeviceKeySignParams;
|
use codex_app_server_protocol::DeviceKeySignParams;
|
||||||
@@ -59,11 +61,15 @@ use codex_app_server_protocol::JSONRPCResponse;
|
|||||||
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
|
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
|
||||||
use codex_app_server_protocol::ServerNotification;
|
use codex_app_server_protocol::ServerNotification;
|
||||||
use codex_app_server_protocol::ServerRequestPayload;
|
use codex_app_server_protocol::ServerRequestPayload;
|
||||||
|
use codex_app_server_protocol::WriteStatus;
|
||||||
use codex_app_server_protocol::experimental_required_message;
|
use codex_app_server_protocol::experimental_required_message;
|
||||||
use codex_arg0::Arg0DispatchPaths;
|
use codex_arg0::Arg0DispatchPaths;
|
||||||
use codex_chatgpt::connectors;
|
use codex_chatgpt::connectors;
|
||||||
|
use codex_config::CONFIG_TOML_FILE;
|
||||||
use codex_core::ThreadManager;
|
use codex_core::ThreadManager;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
|
use codex_core_plugins::remote;
|
||||||
|
use codex_core_plugins::remote::RemotePluginEnableOrDisableBatchMessage;
|
||||||
use codex_exec_server::EnvironmentManager;
|
use codex_exec_server::EnvironmentManager;
|
||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_feedback::CodexFeedback;
|
use codex_feedback::CodexFeedback;
|
||||||
@@ -83,6 +89,7 @@ use codex_protocol::ThreadId;
|
|||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
use codex_protocol::protocol::W3cTraceContext;
|
use codex_protocol::protocol::W3cTraceContext;
|
||||||
use codex_state::log_db::LogDbLayer;
|
use codex_state::log_db::LogDbLayer;
|
||||||
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
@@ -163,6 +170,7 @@ pub(crate) struct MessageProcessor {
|
|||||||
outgoing: Arc<OutgoingMessageSender>,
|
outgoing: Arc<OutgoingMessageSender>,
|
||||||
codex_message_processor: CodexMessageProcessor,
|
codex_message_processor: CodexMessageProcessor,
|
||||||
thread_manager: Arc<ThreadManager>,
|
thread_manager: Arc<ThreadManager>,
|
||||||
|
config_manager: ConfigManager,
|
||||||
config_api: ConfigApi,
|
config_api: ConfigApi,
|
||||||
device_key_api: DeviceKeyApi,
|
device_key_api: DeviceKeyApi,
|
||||||
external_agent_config_api: ExternalAgentConfigApi,
|
external_agent_config_api: ExternalAgentConfigApi,
|
||||||
@@ -323,7 +331,7 @@ impl MessageProcessor {
|
|||||||
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
|
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
|
||||||
}
|
}
|
||||||
let config_api = ConfigApi::new(
|
let config_api = ConfigApi::new(
|
||||||
config_manager,
|
config_manager.clone(),
|
||||||
thread_manager.clone(),
|
thread_manager.clone(),
|
||||||
analytics_events_client.clone(),
|
analytics_events_client.clone(),
|
||||||
);
|
);
|
||||||
@@ -343,6 +351,7 @@ impl MessageProcessor {
|
|||||||
outgoing,
|
outgoing,
|
||||||
codex_message_processor,
|
codex_message_processor,
|
||||||
thread_manager: Arc::clone(&thread_manager),
|
thread_manager: Arc::clone(&thread_manager),
|
||||||
|
config_manager,
|
||||||
config_api,
|
config_api,
|
||||||
device_key_api,
|
device_key_api,
|
||||||
external_agent_config_api,
|
external_agent_config_api,
|
||||||
@@ -823,10 +832,12 @@ impl MessageProcessor {
|
|||||||
}
|
}
|
||||||
ClientRequest::ConfigValueWrite { request_id, params } => {
|
ClientRequest::ConfigValueWrite { request_id, params } => {
|
||||||
self.handle_config_value_write(request_id_for_connection(request_id), params)
|
self.handle_config_value_write(request_id_for_connection(request_id), params)
|
||||||
|
.boxed()
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
ClientRequest::ConfigBatchWrite { request_id, params } => {
|
ClientRequest::ConfigBatchWrite { request_id, params } => {
|
||||||
self.handle_config_batch_write(request_id_for_connection(request_id), params)
|
self.handle_config_batch_write(request_id_for_connection(request_id), params)
|
||||||
|
.boxed()
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
|
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
|
||||||
@@ -989,7 +1000,12 @@ impl MessageProcessor {
|
|||||||
request_id: ConnectionRequestId,
|
request_id: ConnectionRequestId,
|
||||||
params: ConfigValueWriteParams,
|
params: ConfigValueWriteParams,
|
||||||
) {
|
) {
|
||||||
let result = self.config_api.write_value(params).await;
|
let result = if remote::is_remote_plugin_enable_or_disable_value_message(¶ms) {
|
||||||
|
self.remote_plugin_enable_or_disable_value_message(params)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.config_api.write_value(params).await
|
||||||
|
};
|
||||||
self.handle_config_mutation_result(request_id, result).await
|
self.handle_config_mutation_result(request_id, result).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,10 +1014,84 @@ impl MessageProcessor {
|
|||||||
request_id: ConnectionRequestId,
|
request_id: ConnectionRequestId,
|
||||||
params: ConfigBatchWriteParams,
|
params: ConfigBatchWriteParams,
|
||||||
) {
|
) {
|
||||||
let result = self.config_api.batch_write(params).await;
|
let result = async {
|
||||||
|
if remote::is_remote_plugin_enable_or_disable_batch_message(¶ms) {
|
||||||
|
self.remote_plugin_enable_or_disable_batch_message(params)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
self.config_api.batch_write(params).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
self.handle_config_mutation_result(request_id, result).await;
|
self.handle_config_mutation_result(request_id, result).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn remote_plugin_enable_or_disable_value_message(
|
||||||
|
&self,
|
||||||
|
params: ConfigValueWriteParams,
|
||||||
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
||||||
|
let action = remote::remote_plugin_enable_or_disable_value_message(¶ms)
|
||||||
|
.ok_or_else(|| invalid_request("invalid remote plugin enablement message"))?;
|
||||||
|
self.codex_message_processor
|
||||||
|
.remote_plugin_enable_or_disable(action.plugin_id, action.enabled)
|
||||||
|
.await?;
|
||||||
|
self.remote_plugin_enable_or_disable_response().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remote_plugin_enable_or_disable_batch_message(
|
||||||
|
&self,
|
||||||
|
params: ConfigBatchWriteParams,
|
||||||
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
||||||
|
match remote::remote_plugin_enable_or_disable_batch_message(¶ms) {
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::RemotePluginToggles(toggles) => {
|
||||||
|
for (plugin_id, enabled) in toggles {
|
||||||
|
self.codex_message_processor
|
||||||
|
.remote_plugin_enable_or_disable(plugin_id, enabled)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::MixedWithLocalConfigEdits => {
|
||||||
|
return Err(invalid_request(
|
||||||
|
"remote plugin enablement edits cannot be batched with local config edits",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::NotRemotePluginToggle => {
|
||||||
|
return Err(invalid_request("invalid remote plugin enablement message"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.remote_plugin_enable_or_disable_response().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remote_plugin_enable_or_disable_response(
|
||||||
|
&self,
|
||||||
|
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
|
||||||
|
let file_path = AbsolutePathBuf::resolve_path_against_base(
|
||||||
|
CONFIG_TOML_FILE,
|
||||||
|
self.config_manager.codex_home(),
|
||||||
|
);
|
||||||
|
let layers = self
|
||||||
|
.config_manager
|
||||||
|
.load_config_layers(/*cwd*/ None)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
internal_error(format!(
|
||||||
|
"failed to read config metadata after remote plugin mutation: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let version = layers
|
||||||
|
.get_user_layer()
|
||||||
|
.map(|layer| layer.version.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(ConfigWriteResponse {
|
||||||
|
status: WriteStatus::Ok,
|
||||||
|
version,
|
||||||
|
file_path,
|
||||||
|
overridden_metadata: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_experimental_feature_enablement_set(
|
async fn handle_experimental_feature_enablement_set(
|
||||||
&self,
|
&self,
|
||||||
request_id: ConnectionRequestId,
|
request_id: ConnectionRequestId,
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ use axum::http::header::AUTHORIZATION;
|
|||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use codex_app_server_protocol::AppInfo;
|
use codex_app_server_protocol::AppInfo;
|
||||||
use codex_app_server_protocol::AppSummary;
|
use codex_app_server_protocol::AppSummary;
|
||||||
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||||
|
use codex_app_server_protocol::ConfigEdit;
|
||||||
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||||
|
use codex_app_server_protocol::ConfigWriteResponse;
|
||||||
use codex_app_server_protocol::JSONRPCResponse;
|
use codex_app_server_protocol::JSONRPCResponse;
|
||||||
|
use codex_app_server_protocol::MergeStrategy;
|
||||||
use codex_app_server_protocol::PluginAuthPolicy;
|
use codex_app_server_protocol::PluginAuthPolicy;
|
||||||
use codex_app_server_protocol::PluginInstallParams;
|
use codex_app_server_protocol::PluginInstallParams;
|
||||||
use codex_app_server_protocol::PluginInstallResponse;
|
use codex_app_server_protocol::PluginInstallResponse;
|
||||||
use codex_app_server_protocol::RequestId;
|
use codex_app_server_protocol::RequestId;
|
||||||
|
use codex_app_server_protocol::WriteStatus;
|
||||||
use codex_config::types::AuthCredentialsStoreMode;
|
use codex_config::types::AuthCredentialsStoreMode;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use flate2::Compression;
|
use flate2::Compression;
|
||||||
@@ -253,6 +259,156 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn config_value_write_enables_remote_plugin_via_remote_enable_without_config_write()
|
||||||
|
-> 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)?;
|
||||||
|
mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ true).await;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let request_id = send_remote_plugin_enabled_config_value_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!("/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_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");
|
||||||
|
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)?;
|
||||||
|
mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ true).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*/ 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!("/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_disable_without_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)?;
|
||||||
|
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??;
|
||||||
|
|
||||||
|
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!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/disable"),
|
||||||
|
/*expected_count*/ 1,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
|
async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
@@ -1261,6 +1417,25 @@ async fn mount_empty_remote_installed_plugins(server: &MockServer) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mount_remote_plugin_enablement(
|
||||||
|
server: &MockServer,
|
||||||
|
remote_plugin_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
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(format!(
|
||||||
|
r#"{{"id":"{remote_plugin_id}","enabled":{enabled}}}"#
|
||||||
|
)))
|
||||||
|
.mount(server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) {
|
async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) {
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path(format!(
|
.and(path(format!(
|
||||||
@@ -1319,6 +1494,51 @@ async fn send_remote_plugin_install_request(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_remote_plugin_enabled_config_write(
|
||||||
|
mcp: &mut McpProcess,
|
||||||
|
remote_plugin_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<i64> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_remote_plugin_enabled_config_value_write(
|
||||||
|
mcp: &mut McpProcess,
|
||||||
|
remote_plugin_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<i64> {
|
||||||
|
mcp.send_config_value_write_request(ConfigValueWriteParams {
|
||||||
|
key_path: format!("plugins.{remote_plugin_id}.enabled"),
|
||||||
|
value: json!(enabled),
|
||||||
|
merge_strategy: MergeStrategy::Upsert,
|
||||||
|
file_path: None,
|
||||||
|
expected_version: None,
|
||||||
|
})
|
||||||
|
.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(
|
async fn wait_for_remote_plugin_request_count(
|
||||||
server: &MockServer,
|
server: &MockServer,
|
||||||
method_name: &str,
|
method_name: &str,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::store::PLUGINS_CACHE_DIR;
|
use crate::store::PLUGINS_CACHE_DIR;
|
||||||
use crate::store::PluginStore;
|
use crate::store::PluginStore;
|
||||||
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||||
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||||
use codex_app_server_protocol::PluginAuthPolicy;
|
use codex_app_server_protocol::PluginAuthPolicy;
|
||||||
use codex_app_server_protocol::PluginInstallPolicy;
|
use codex_app_server_protocol::PluginInstallPolicy;
|
||||||
use codex_app_server_protocol::PluginInterface;
|
use codex_app_server_protocol::PluginInterface;
|
||||||
@@ -9,6 +11,7 @@ use codex_login::default_client::build_reqwest_client;
|
|||||||
use codex_plugin::PluginId;
|
use codex_plugin::PluginId;
|
||||||
use reqwest::RequestBuilder;
|
use reqwest::RequestBuilder;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -71,6 +74,19 @@ pub struct RemotePluginSkill {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RemotePluginEnableOrDisable {
|
||||||
|
pub plugin_id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RemotePluginEnableOrDisableBatchMessage {
|
||||||
|
NotRemotePluginToggle,
|
||||||
|
RemotePluginToggles(BTreeMap<String, bool>),
|
||||||
|
MixedWithLocalConfigEdits,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RemotePluginCatalogError {
|
pub enum RemotePluginCatalogError {
|
||||||
#[error("chatgpt authentication required for remote plugin catalog")]
|
#[error("chatgpt authentication required for remote plugin catalog")]
|
||||||
@@ -523,24 +539,104 @@ pub async fn install_remote_plugin(
|
|||||||
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
|
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
|
||||||
let client = build_reqwest_client();
|
let client = build_reqwest_client();
|
||||||
let request = authenticated_request(client.post(&url), auth)?;
|
let request = authenticated_request(client.post(&url), auth)?;
|
||||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ true).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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_remote_plugin_enable_or_disable_value_message(params: &ConfigValueWriteParams) -> bool {
|
||||||
|
remote_plugin_enable_or_disable_value_message(params).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_plugin_enable_or_disable_value_message(
|
||||||
|
params: &ConfigValueWriteParams,
|
||||||
|
) -> Option<RemotePluginEnableOrDisable> {
|
||||||
|
remote_plugin_enable_or_disable_edit(¶ms.key_path, ¶ms.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_remote_plugin_enable_or_disable_batch_message(params: &ConfigBatchWriteParams) -> bool {
|
||||||
|
!matches!(
|
||||||
|
remote_plugin_enable_or_disable_batch_message(params),
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::NotRemotePluginToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_plugin_enable_or_disable_batch_message(
|
||||||
|
params: &ConfigBatchWriteParams,
|
||||||
|
) -> RemotePluginEnableOrDisableBatchMessage {
|
||||||
|
let mut toggles = BTreeMap::<String, bool>::new();
|
||||||
|
let mut has_local_edits = false;
|
||||||
|
|
||||||
|
for edit in ¶ms.edits {
|
||||||
|
if let Some(action) = remote_plugin_enable_or_disable_edit(&edit.key_path, &edit.value) {
|
||||||
|
toggles.insert(action.plugin_id, action.enabled);
|
||||||
|
} else {
|
||||||
|
has_local_edits = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toggles.is_empty() {
|
||||||
|
return RemotePluginEnableOrDisableBatchMessage::NotRemotePluginToggle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_local_edits {
|
||||||
|
return RemotePluginEnableOrDisableBatchMessage::MixedWithLocalConfigEdits;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::RemotePluginToggles(toggles)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_plugin_enable_or_disable_edit(
|
||||||
|
key_path: &str,
|
||||||
|
value: &JsonValue,
|
||||||
|
) -> Option<RemotePluginEnableOrDisable> {
|
||||||
|
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(RemotePluginEnableOrDisable {
|
||||||
|
plugin_id: 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_"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn uninstall_remote_plugin(
|
pub async fn uninstall_remote_plugin(
|
||||||
config: &RemotePluginServiceConfig,
|
config: &RemotePluginServiceConfig,
|
||||||
auth: Option<&CodexAuth>,
|
auth: Option<&CodexAuth>,
|
||||||
@@ -553,20 +649,7 @@ pub async fn uninstall_remote_plugin(
|
|||||||
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
||||||
let client = build_reqwest_client();
|
let client = build_reqwest_client();
|
||||||
let request = authenticated_request(client.post(&url), auth)?;
|
let request = authenticated_request(client.post(&url), auth)?;
|
||||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ false).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 {
|
let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await {
|
||||||
Ok(remote_detail) => Some(remote_detail),
|
Ok(remote_detail) => Some(remote_detail),
|
||||||
@@ -593,6 +676,30 @@ pub async fn uninstall_remote_plugin(
|
|||||||
Ok(())
|
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(
|
fn remove_remote_plugin_cache(
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
remote_detail: Option<RemotePluginDetail>,
|
remote_detail: Option<RemotePluginDetail>,
|
||||||
@@ -891,3 +998,82 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use codex_app_server_protocol::ConfigEdit;
|
||||||
|
use codex_app_server_protocol::MergeStrategy;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_write_message_detects_remote_plugin_enable_or_disable() {
|
||||||
|
let params = ConfigValueWriteParams {
|
||||||
|
key_path: format!("plugins.{REMOTE_PLUGIN_ID}.enabled"),
|
||||||
|
value: json!(false),
|
||||||
|
merge_strategy: MergeStrategy::Upsert,
|
||||||
|
file_path: None,
|
||||||
|
expected_version: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(is_remote_plugin_enable_or_disable_value_message(¶ms));
|
||||||
|
assert_eq!(
|
||||||
|
remote_plugin_enable_or_disable_value_message(¶ms),
|
||||||
|
Some(RemotePluginEnableOrDisable {
|
||||||
|
plugin_id: REMOTE_PLUGIN_ID.to_string(),
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_write_message_rejects_mixed_remote_and_local_edits() {
|
||||||
|
let params = ConfigBatchWriteParams {
|
||||||
|
edits: vec![
|
||||||
|
ConfigEdit {
|
||||||
|
key_path: format!("plugins.{REMOTE_PLUGIN_ID}.enabled"),
|
||||||
|
value: json!(true),
|
||||||
|
merge_strategy: MergeStrategy::Upsert,
|
||||||
|
},
|
||||||
|
ConfigEdit {
|
||||||
|
key_path: "model".to_string(),
|
||||||
|
value: json!("gpt-5"),
|
||||||
|
merge_strategy: MergeStrategy::Replace,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
file_path: None,
|
||||||
|
expected_version: None,
|
||||||
|
reload_user_config: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(is_remote_plugin_enable_or_disable_batch_message(¶ms));
|
||||||
|
assert_eq!(
|
||||||
|
remote_plugin_enable_or_disable_batch_message(¶ms),
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::MixedWithLocalConfigEdits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_write_message_collects_remote_plugin_toggles() {
|
||||||
|
let params = ConfigBatchWriteParams {
|
||||||
|
edits: vec![ConfigEdit {
|
||||||
|
key_path: format!("plugins.{REMOTE_PLUGIN_ID}.enabled"),
|
||||||
|
value: json!(true),
|
||||||
|
merge_strategy: MergeStrategy::Upsert,
|
||||||
|
}],
|
||||||
|
file_path: None,
|
||||||
|
expected_version: None,
|
||||||
|
reload_user_config: true,
|
||||||
|
};
|
||||||
|
let mut expected = BTreeMap::new();
|
||||||
|
expected.insert(REMOTE_PLUGIN_ID.to_string(), true);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
remote_plugin_enable_or_disable_batch_message(¶ms),
|
||||||
|
RemotePluginEnableOrDisableBatchMessage::RemotePluginToggles(expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user