Compare commits

...

8 Commits

Author SHA1 Message Date
xli-oai
a5a9651e3e Move remote plugin toggle parsing to remote module 2026-04-28 23:24:16 -07:00
xli-oai
133f5b371c Make remote plugin toggle routing explicit 2026-04-28 22:52:38 -07:00
xli-oai
0af813a408 Clarify remote plugin config write routing 2026-04-28 22:17:31 -07:00
xli-oai
bdcf098809 Use direct remote plugin config sync 2026-04-28 22:05:27 -07:00
xli-oai
559cfce5a5 Route remote plugin config toggles outside ConfigApi 2026-04-28 21:16:52 -07:00
xli-oai
656f6f5a39 Keep remote plugin config sync out of message dispatcher 2026-04-28 20:27:41 -07:00
xli-oai
010064afba Use remote enablement for plugin config toggles 2026-04-28 19:06:42 -07:00
xli-oai
d01cf95bba Sync remote plugin config toggles 2026-04-28 19:06:42 -07:00
5 changed files with 569 additions and 32 deletions

View File

@@ -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.<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`.
### Example: Start or resume a thread

View File

@@ -4,6 +4,47 @@ use crate::error_code::invalid_request;
use codex_app_server_protocol::PluginInstallPolicy;
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(
&self,
request_id: ConnectionRequestId,

View File

@@ -11,6 +11,7 @@ use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::device_key_api::DeviceKeyApi;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::external_agent_config_api::ExternalAgentConfigApi;
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::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;
@@ -59,11 +61,15 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::WriteStatus;
use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::connectors;
use codex_config::CONFIG_TOML_FILE;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core_plugins::remote;
use codex_core_plugins::remote::RemotePluginEnableOrDisableBatchMessage;
use codex_exec_server::EnvironmentManager;
use codex_features::Feature;
use codex_feedback::CodexFeedback;
@@ -83,6 +89,7 @@ use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_state::log_db::LogDbLayer;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::FutureExt;
use tokio::sync::broadcast;
use tokio::sync::watch;
@@ -163,6 +170,7 @@ pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_message_processor: CodexMessageProcessor,
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
config_api: ConfigApi,
device_key_api: DeviceKeyApi,
external_agent_config_api: ExternalAgentConfigApi,
@@ -323,7 +331,7 @@ impl MessageProcessor {
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
}
let config_api = ConfigApi::new(
config_manager,
config_manager.clone(),
thread_manager.clone(),
analytics_events_client.clone(),
);
@@ -343,6 +351,7 @@ impl MessageProcessor {
outgoing,
codex_message_processor,
thread_manager: Arc::clone(&thread_manager),
config_manager,
config_api,
device_key_api,
external_agent_config_api,
@@ -823,10 +832,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 +1000,12 @@ impl MessageProcessor {
request_id: ConnectionRequestId,
params: ConfigValueWriteParams,
) {
let result = self.config_api.write_value(params).await;
let result = if remote::is_remote_plugin_enable_or_disable_value_message(&params) {
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
}
@@ -998,10 +1014,84 @@ impl MessageProcessor {
request_id: ConnectionRequestId,
params: ConfigBatchWriteParams,
) {
let result = self.config_api.batch_write(params).await;
let result = async {
if remote::is_remote_plugin_enable_or_disable_batch_message(&params) {
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;
}
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(&params)
.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(&params) {
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(
&self,
request_id: ConnectionRequestId,

View File

@@ -21,11 +21,17 @@ 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::ConfigValueWriteParams;
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 +259,156 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
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]
async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1261,6 +1417,25 @@ async fn mount_empty_remote_installed_plugins(server: &MockServer) {
.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) {
Mock::given(method("POST"))
.and(path(format!(
@@ -1319,6 +1494,51 @@ 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<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(
server: &MockServer,
method_name: &str,

View File

@@ -1,5 +1,7 @@
use crate::store::PLUGINS_CACHE_DIR;
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::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
@@ -9,6 +11,7 @@ use codex_login::default_client::build_reqwest_client;
use codex_plugin::PluginId;
use reqwest::RequestBuilder;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
@@ -71,6 +74,19 @@ pub struct RemotePluginSkill {
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)]
pub enum RemotePluginCatalogError {
#[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 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(())
}
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(&params.key_path, &params.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 &params.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(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -553,20 +649,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 +676,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<RemotePluginDetail>,
@@ -891,3 +998,82 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
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(&params));
assert_eq!(
remote_plugin_enable_or_disable_value_message(&params),
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(&params));
assert_eq!(
remote_plugin_enable_or_disable_batch_message(&params),
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(&params),
RemotePluginEnableOrDisableBatchMessage::RemotePluginToggles(expected)
);
}
}