feat: support remote_sync for plugin install/uninstall. (#14878)

- Added forceRemoteSync to plugin/install and plugin/uninstall.
- With forceRemoteSync=true, we update the remote plugin status first,
then apply the local change only if the backend call succeeds.
- Kept plugin/list(forceRemoteSync=true) as the main recon path, and for
now it treats remote enabled=false as uninstall. We
will eventually migrate to plugin/installed for more precise state
handling.
This commit is contained in:
xl-openai
2026-03-16 21:37:27 -07:00
committed by GitHub
parent 49c2b66ece
commit 1d85fe79ed
17 changed files with 743 additions and 101 deletions

View File

@@ -44,6 +44,12 @@ use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -86,6 +92,7 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
codex_home.path().join("missing-marketplace.json"),
)?,
plugin_name: "missing-plugin".to_string(),
force_remote_sync: false,
})
.await?;
@@ -124,6 +131,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
force_remote_sync: false,
})
.await?;
@@ -138,6 +146,76 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
Ok(())
}
#[tokio::test]
async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()>
{
let server = MockServer::start().await;
let codex_home = TempDir::new()?;
write_plugin_remote_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
None,
None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
Mock::given(method("POST"))
.and(path("/backend-api/plugins/sample-plugin@debug/enable"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
force_remote_sync: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginInstallResponse = to_response(response)?;
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
assert!(
codex_home
.path()
.join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json")
.is_file()
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
assert!(config.contains("enabled = true"));
Ok(())
}
#[tokio::test]
async fn plugin_install_tracks_analytics_event() -> Result<()> {
let analytics_server = start_analytics_events_server().await?;
@@ -172,6 +250,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -285,6 +364,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
force_remote_sync: false,
})
.await?;
@@ -367,6 +447,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
force_remote_sync: false,
})
.await?;
@@ -549,6 +630,23 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::
)
}
fn write_plugin_remote_sync_config(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
[features]
plugins = true
"#
),
)
}
fn write_plugin_marketplace(
repo_root: &std::path::Path,
marketplace_name: &str,

View File

@@ -630,6 +630,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
)?;
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail", "calendar"])?;
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
write_installed_plugin(&codex_home, "openai-curated", "gmail")?;
write_installed_plugin(&codex_home, "openai-curated", "calendar")?;
Mock::given(method("GET"))
@@ -676,14 +677,14 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
.collect::<Vec<_>>(),
vec![
("linear@openai-curated".to_string(), true, true),
("gmail@openai-curated".to_string(), true, false),
("gmail@openai-curated".to_string(), false, false),
("calendar@openai-curated".to_string(), false, false),
]
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
assert!(
@@ -693,12 +694,10 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
.is_dir()
);
assert!(
codex_home
!codex_home
.path()
.join(format!(
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
))
.is_dir()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
assert!(
!codex_home
@@ -741,6 +740,9 @@ plugins = true
[plugins."linear@openai-curated"]
enabled = false
[plugins."gmail@openai-curated"]
enabled = false
[plugins."calendar@openai-curated"]
enabled = true
"#

View File

@@ -16,6 +16,12 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -38,6 +44,7 @@ enabled = true
let params = PluginUninstallParams {
plugin_id: "sample-plugin@debug".to_string(),
force_remote_sync: false,
};
let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?;
@@ -70,6 +77,74 @@ enabled = true
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> {
let server = MockServer::start().await;
let codex_home = TempDir::new()?;
write_installed_plugin(&codex_home, "debug", "sample-plugin")?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"chatgpt_base_url = "{}/backend-api/"
[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("POST"))
.and(path("/backend-api/plugins/sample-plugin@debug/uninstall"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: "sample-plugin@debug".to_string(),
force_remote_sync: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginUninstallResponse = to_response(response)?;
assert_eq!(response, PluginUninstallResponse {});
assert!(
!codex_home
.path()
.join("plugins/cache/debug/sample-plugin")
.exists()
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#));
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
let analytics_server = start_analytics_events_server().await?;
@@ -97,6 +172,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: "sample-plugin@debug".to_string(),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(