mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
8 Commits
dev/jm/dev
...
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/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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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
|
||||
}
|
||||
|
||||
@@ -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(¶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;
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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(
|
||||
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(¶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