mirror of
https://github.com/openai/codex.git
synced 2026-09-07 15:40:00 +00:00
Use remote enablement for plugin config toggles
This commit is contained in:
@@ -222,7 +222,7 @@ Example with notification opt-out:
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name.
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish).
|
||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. For remote ChatGPT plugin IDs, `plugins.<remotePluginId>.enabled` is a compatibility write that is translated into remote install/uninstall instead of being persisted to config.toml.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. For remote ChatGPT plugin IDs, `plugins.<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
|
||||
|
||||
@@ -9,28 +9,13 @@ impl CodexMessageProcessor {
|
||||
plugin_id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if enabled {
|
||||
let remote_marketplace_name =
|
||||
self.remote_marketplace_name_for_plugin(&plugin_id).await?;
|
||||
self.remote_plugin_install_response(remote_marketplace_name, plugin_id)
|
||||
.await?;
|
||||
} else {
|
||||
self.remote_plugin_uninstall_response(plugin_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remote_marketplace_name_for_plugin(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
) -> Result<String, 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 enable is not enabled"));
|
||||
return Err(invalid_request("remote plugin enablement is not enabled"));
|
||||
}
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(plugin_id) {
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
|
||||
));
|
||||
@@ -40,23 +25,24 @@ impl CodexMessageProcessor {
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let remote_marketplaces = codex_core_plugins::remote::fetch_remote_marketplaces(
|
||||
codex_core_plugins::remote::set_remote_plugin_enabled(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&plugin_id,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "find remote plugin"))?;
|
||||
.map_err(|err| {
|
||||
let context = if enabled {
|
||||
"enable remote plugin"
|
||||
} else {
|
||||
"disable remote plugin"
|
||||
};
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, context)
|
||||
})?;
|
||||
|
||||
remote_marketplaces
|
||||
.into_iter()
|
||||
.find_map(|marketplace| {
|
||||
marketplace
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.id == plugin_id)
|
||||
.then_some(marketplace.name)
|
||||
})
|
||||
.ok_or_else(|| invalid_request(format!("remote plugin `{plugin_id}` was not found")))
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_list(
|
||||
|
||||
@@ -259,33 +259,22 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_batch_write_enables_remote_plugin_via_remote_install_and_cache() -> Result<()> {
|
||||
async fn config_batch_write_enables_remote_plugin_via_remote_enable_without_cache_install()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_marketplaces_for_toggle(&server, REMOTE_PLUGIN_ID, /*enabled*/ false).await;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_remote_plugin_install_after_cache_write(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
std::fs::create_dir_all(installed_path.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
installed_path.join(".codex-plugin/plugin.json"),
|
||||
)
|
||||
.await;
|
||||
r#"{"name":"linear"}"#,
|
||||
)?;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ true).await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id =
|
||||
@@ -302,17 +291,24 @@ async fn config_batch_write_enables_remote_plugin_via_remote_install_and_cache()
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
||||
&format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/enable"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
assert_config_does_not_contain_remote_plugin_id(codex_home.path(), REMOTE_PLUGIN_ID)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cache_removal()
|
||||
async fn config_batch_write_disables_remote_plugin_via_remote_disable_without_cache_removal()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
@@ -325,20 +321,7 @@ async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cach
|
||||
r#"{"name":"linear"}"#,
|
||||
)?;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
mount_remote_plugin_detail_without_download_urls(&server, REMOTE_PLUGIN_ID, "1.2.3").await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_enablement(&server, REMOTE_PLUGIN_ID, /*enabled*/ false).await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
@@ -357,16 +340,18 @@ async fn config_batch_write_disables_remote_plugin_via_remote_uninstall_and_cach
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
&format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/disable"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.exists()
|
||||
);
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
assert_config_does_not_contain_remote_plugin_id(codex_home.path(), REMOTE_PLUGIN_ID)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1379,115 +1364,23 @@ async fn mount_empty_remote_installed_plugins(server: &MockServer) {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_marketplaces_for_toggle(
|
||||
async fn mount_remote_plugin_enablement(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
enabled: bool,
|
||||
) {
|
||||
let global_directory_body = format!(
|
||||
r#"{{
|
||||
"plugins": [
|
||||
{{
|
||||
"id": "{remote_plugin_id}",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {{
|
||||
"short_description": "Plan and track work"
|
||||
}},
|
||||
"skills": []
|
||||
}}
|
||||
}}
|
||||
],
|
||||
"pagination": {{
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
let global_installed_body = if enabled {
|
||||
format!(
|
||||
r#"{{
|
||||
"plugins": [
|
||||
{{
|
||||
"id": "{remote_plugin_id}",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {{
|
||||
"short_description": "Plan and track work"
|
||||
}},
|
||||
"skills": []
|
||||
}},
|
||||
"enabled": true,
|
||||
"disabled_skill_names": []
|
||||
}}
|
||||
],
|
||||
"pagination": {{
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}}
|
||||
}}"#
|
||||
)
|
||||
} else {
|
||||
empty_remote_plugin_page_body()
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(query_param("limit", "200"))
|
||||
let action = if enabled { "enable" } else { "disable" };
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{remote_plugin_id}/{action}"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{"id":"{remote_plugin_id}","enabled":{enabled}}}"#
|
||||
)))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(empty_remote_plugin_page_body()))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(empty_remote_plugin_page_body()))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn empty_remote_plugin_page_body() -> String {
|
||||
r#"{
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) {
|
||||
@@ -1505,40 +1398,6 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_without_download_urls(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
) {
|
||||
let detail_body = format!(
|
||||
r#"{{
|
||||
"id": "{remote_plugin_id}",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"version": "{release_version}",
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {{
|
||||
"short_description": "Plan and track work"
|
||||
}},
|
||||
"skills": []
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheManifestExists {
|
||||
manifest_path: std::path::PathBuf,
|
||||
|
||||
@@ -523,20 +523,25 @@ pub async fn install_remote_plugin(
|
||||
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
if response.id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.id,
|
||||
});
|
||||
}
|
||||
if !response.enabled {
|
||||
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled: true,
|
||||
actual_enabled: response.enabled,
|
||||
});
|
||||
}
|
||||
send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ true).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_remote_plugin_enabled(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let action = if enabled { "enable" } else { "disable" };
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/{plugin_id}/{action}");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
send_remote_plugin_mutation(request, &url, plugin_id, enabled).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -553,20 +558,7 @@ pub async fn uninstall_remote_plugin(
|
||||
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
if response.id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.id,
|
||||
});
|
||||
}
|
||||
if response.enabled {
|
||||
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled: false,
|
||||
actual_enabled: response.enabled,
|
||||
});
|
||||
}
|
||||
send_remote_plugin_mutation(request, &url, plugin_id, /*expected_enabled*/ false).await?;
|
||||
|
||||
let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await {
|
||||
Ok(remote_detail) => Some(remote_detail),
|
||||
@@ -593,6 +585,30 @@ pub async fn uninstall_remote_plugin(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_remote_plugin_mutation(
|
||||
request: RequestBuilder,
|
||||
url: &str,
|
||||
plugin_id: &str,
|
||||
expected_enabled: bool,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, url).await?;
|
||||
if response.id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.id,
|
||||
});
|
||||
}
|
||||
if response.enabled != expected_enabled {
|
||||
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled,
|
||||
actual_enabled: response.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_remote_plugin_cache(
|
||||
codex_home: PathBuf,
|
||||
remote_detail: Option<RemotePluginDetail>,
|
||||
|
||||
Reference in New Issue
Block a user