mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## What changed - Add the `plugin/reconcile` JSON-RPC method to synchronize installed remote plugin bundles and wait for required hook updates. - Return affected plugin IDs with MCP, Apps, hooks, and skills refresh hints, plus remote update and materialization failures. - Refresh loaded hook runtimes after relevant updates and return an empty result when plugins or backend authentication are unavailable. ## Testing - Cover workspace and global bundle installs, unchanged passes, enablement changes, updates, removals, materialization failures, hook refreshes, and disabled plugins. GitOrigin-RevId: 45c02518d71990d13eb09d56ed51505c91c66956
303 lines
11 KiB
Rust
303 lines
11 KiB
Rust
//! Exercises blocking plugin reconciliation through the public JSON-RPC API.
|
|
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_app_server_protocol::PluginReconcileChangedPlugin;
|
|
use codex_app_server_protocol::PluginReconcileResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_features::Feature;
|
|
use core_test_support::responses;
|
|
use flate2::Compression;
|
|
use flate2::write::GzEncoder;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
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;
|
|
use wiremock::matchers::query_param;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
#[test_case("WORKSPACE", "workspace-directory"; "workspace")]
|
|
#[test_case("GLOBAL", "openai-curated-remote"; "global")]
|
|
#[tokio::test]
|
|
async fn plugin_reconcile_syncs_bundles_and_reports_changes(
|
|
scope: &str,
|
|
marketplace: &str,
|
|
) -> Result<()> {
|
|
let server = MockServer::start().await;
|
|
// Both scopes must retain background-sync behavior with remote_plugin disabled.
|
|
let (mut app_server, codex_home) = start_app_server(&server).await?;
|
|
let thread = app_server.start_thread(Default::default()).await?.thread;
|
|
let bundle_url = format!("{}/bundle", server.uri());
|
|
let plugin_id = format!("linear@{marketplace}");
|
|
let first = PluginReconcileChangedPlugin {
|
|
id: plugin_id.clone(),
|
|
has_mcps: true,
|
|
has_apps: false,
|
|
has_hooks: true,
|
|
has_skills: true,
|
|
};
|
|
let second = PluginReconcileChangedPlugin {
|
|
id: plugin_id.clone(),
|
|
has_mcps: false,
|
|
has_apps: true,
|
|
has_hooks: false,
|
|
has_skills: false,
|
|
};
|
|
let updated = PluginReconcileChangedPlugin {
|
|
id: plugin_id,
|
|
has_mcps: true,
|
|
has_apps: true,
|
|
has_hooks: true,
|
|
has_skills: true,
|
|
};
|
|
let failed_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
let mut missing_bundle = installed_plugin("1.0.0", &bundle_url, "WORKSPACE");
|
|
missing_bundle["id"] = json!(failed_id);
|
|
missing_bundle["name"] = json!("missing-bundle");
|
|
missing_bundle["release"]["bundle_download_url"] = Value::Null;
|
|
let mut first_pass_failure = Some(missing_bundle);
|
|
|
|
// v1.1 drops hooks; v2 restores them and drops Apps. Both updates must report
|
|
// both sides, while uninstall below reports only v2's remaining capabilities.
|
|
// Enablement changes report cached capabilities without downloading the bundle again.
|
|
for (version, enabled, capabilities, expected_plugins, expected_downloads) in [
|
|
("1.0.0", true, &first, vec![first.clone()], 1),
|
|
("1.0.0", true, &first, Vec::new(), 0),
|
|
("1.0.0", false, &first, vec![first.clone()], 0),
|
|
("1.0.0", true, &first, vec![first.clone()], 0),
|
|
("1.1.0", true, &second, vec![updated.clone()], 1),
|
|
("2.0.0", true, &first, vec![updated], 1),
|
|
] {
|
|
let mut files = vec![(
|
|
".codex-plugin/plugin.json",
|
|
json!({"name": "linear"}).to_string(),
|
|
)];
|
|
if capabilities.has_mcps {
|
|
files.push((
|
|
".mcp.json",
|
|
json!({"mcpServers": {"example": {"command": "unused", "enabled": false}}})
|
|
.to_string(),
|
|
));
|
|
}
|
|
if capabilities.has_apps {
|
|
files.push((
|
|
".app.json",
|
|
json!({"apps": {"example": {"id": "connector_example"}}}).to_string(),
|
|
));
|
|
}
|
|
if capabilities.has_hooks {
|
|
files.push((
|
|
"hooks/hooks.json",
|
|
json!({"hooks": {"UserPromptSubmit": [{"hooks": [{
|
|
"type": "command", "command": format!("echo hook {version}")
|
|
}]}]}})
|
|
.to_string(),
|
|
));
|
|
}
|
|
if capabilities.has_skills {
|
|
files.push((
|
|
"skills/example/SKILL.md",
|
|
"---\nname: example\ndescription: Example skill\n---\nExample skill".to_string(),
|
|
));
|
|
}
|
|
let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
|
|
for (path, contents) in files {
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(contents.len() as u64);
|
|
header.set_mode(/*mode*/ 0o644);
|
|
header.set_cksum();
|
|
archive.append_data(&mut header, path, contents.as_bytes())?;
|
|
}
|
|
let bundle = archive.into_inner()?.finish()?;
|
|
let mut plugin = installed_plugin(version, &bundle_url, scope);
|
|
plugin["enabled"] = json!(enabled);
|
|
let mut plugins = vec![plugin];
|
|
let mut failures = Vec::new();
|
|
if let Some(failure) = first_pass_failure.take() {
|
|
plugins.push(failure);
|
|
failures.push(failed_id.to_string());
|
|
}
|
|
let expected = PluginReconcileResponse {
|
|
changed_plugins: expected_plugins,
|
|
failed_remote_plugin_ids: failures.clone(),
|
|
failed_materialization_remote_plugin_ids: failures,
|
|
};
|
|
mount_installed_snapshot(&server, plugins).await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/bundle"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(bundle))
|
|
.expect(expected_downloads)
|
|
.mount(&server)
|
|
.await;
|
|
assert_eq!(reconcile(&mut app_server).await?, expected);
|
|
// A turn exercises the loaded registry, not fresh hooks/list discovery. Hooks run
|
|
// on the app-server host even when the thread uses a remote executor.
|
|
assert_eq!(
|
|
turn_hook_runs(&mut app_server, &server, thread.id.clone()).await?,
|
|
usize::from(scope == "WORKSPACE" && enabled && capabilities.has_hooks),
|
|
);
|
|
server.verify().await;
|
|
server.reset().await;
|
|
}
|
|
|
|
// Uninstall must report removed capabilities and stop already-loaded hooks.
|
|
mount_installed_snapshot(&server, Vec::new()).await;
|
|
assert_eq!(
|
|
reconcile(&mut app_server).await?,
|
|
PluginReconcileResponse {
|
|
changed_plugins: vec![first],
|
|
..Default::default()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
turn_hook_runs(&mut app_server, &server, thread.id.clone()).await?,
|
|
0
|
|
);
|
|
server.verify().await;
|
|
server.reset().await;
|
|
|
|
// The next RPC must honor the latest config without contacting the plugin service.
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(
|
|
config_path,
|
|
config.replace("plugins = true", "plugins = false"),
|
|
)?;
|
|
assert_eq!(
|
|
reconcile(&mut app_server).await?,
|
|
PluginReconcileResponse::default()
|
|
);
|
|
let requests = server.received_requests().await.expect("recorded requests");
|
|
assert!(requests.iter().all(|request| {
|
|
!request.url.path().starts_with("/backend-api/ps/plugins/")
|
|
&& request.url.path() != "/bundle"
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
async fn turn_hook_runs(
|
|
app_server: &mut TestAppServer,
|
|
server: &MockServer,
|
|
thread_id: String,
|
|
) -> Result<usize> {
|
|
let _response = responses::mount_sse_once(
|
|
server,
|
|
responses::sse(vec![
|
|
responses::ev_response_created("response"),
|
|
responses::ev_completed("response"),
|
|
]),
|
|
)
|
|
.await;
|
|
app_server.clear_message_buffer();
|
|
let completed = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id,
|
|
input: vec![UserInput::Text {
|
|
text: "run hooks".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await??;
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
Ok(app_server
|
|
.pending_notification_methods()
|
|
.iter()
|
|
.filter(|method| method.as_str() == "hook/started")
|
|
.count())
|
|
}
|
|
|
|
async fn reconcile(app_server: &mut TestAppServer) -> Result<PluginReconcileResponse> {
|
|
let request = app_server
|
|
.send_raw_request(
|
|
"plugin/reconcile",
|
|
Some(json!({"reason": "tooling_changed"})),
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request)).await?
|
|
}
|
|
|
|
async fn start_app_server(server: &MockServer) -> Result<(TestAppServer, TempDir)> {
|
|
let codex_home = TempDir::new()?;
|
|
let base_url = format!("{}/backend-api/", server.uri());
|
|
MockResponsesConfig::new(&server.uri())
|
|
.with_root_config(&format!("chatgpt_base_url = \"{base_url}\""))
|
|
.disable_feature(Feature::Apps)
|
|
.enable_feature(Feature::Plugins)
|
|
.enable_feature(Feature::CodexHooks)
|
|
.disable_feature(Feature::RemotePlugin)
|
|
.disable_feature(Feature::PluginSharing)
|
|
.write(codex_home.path())?;
|
|
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 app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.with_env_overrides(&[(
|
|
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS",
|
|
Some("1"),
|
|
)])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
Ok((app_server, codex_home))
|
|
}
|
|
|
|
async fn mount_installed_snapshot(server: &MockServer, plugins: Vec<Value>) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("includeDownloadUrls", "true"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"plugins": plugins,
|
|
"pagination": { "limit": 200, "next_page_token": null },
|
|
})))
|
|
.expect(1)
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
fn installed_plugin(version: &str, bundle_url: &str, scope: &str) -> Value {
|
|
json!({
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": scope,
|
|
"discoverability": (scope == "WORKSPACE").then_some("LISTED"),
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"version": version,
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"bundle_download_url": bundle_url,
|
|
"interface": {},
|
|
},
|
|
"enabled": true,
|
|
})
|
|
}
|