mirror of
https://github.com/openai/codex.git
synced 2026-08-29 14:09:35 +00:00
Route curated plugins by authentication mode (#35671)
## Why Curated plugin capabilities need to follow the active authentication mode, including after an account switch and when the configured model provider differs from the authentication source. ## What changed - Select the ChatGPT, remote, or API curated marketplace from the current authentication mode, with an API marketplace fallback for ambient Amazon Bedrock credentials. - Apply that selection consistently to plugin loading, hooks, skills, installed-plugin conflict filtering, marketplace listing, and `codex mcp` discovery. - Start the local curated repository sync when an account change makes the remote catalog unavailable, and refresh existing thread MCP runtimes when the effective plugin cache changes. ## Testing Added coverage for account switches, ChatGPT-authenticated Bedrock sessions, API-key MCP discovery, curated marketplace filtering, hook and skill routing, and existing-thread MCP refreshes. GitOrigin-RevId: dbefdba3a3ea7281e7b6013e057a418770ccfc95
This commit is contained in:
@@ -229,19 +229,29 @@ impl AccountRequestProcessor {
|
||||
Arc::clone(thread_manager),
|
||||
config_manager.clone(),
|
||||
);
|
||||
let plugins_config = config.plugins_config_input();
|
||||
let refresh_thread_manager = Arc::clone(thread_manager);
|
||||
let refresh_config_manager = config_manager.clone();
|
||||
let on_effective_plugins_changed: Arc<
|
||||
dyn Fn(codex_core_plugins::EffectivePluginsChange) + Send + Sync,
|
||||
> = Arc::new(move |_change| {
|
||||
Self::spawn_effective_plugins_changed_task(
|
||||
Arc::clone(&refresh_thread_manager),
|
||||
refresh_config_manager.clone(),
|
||||
);
|
||||
});
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_curated_repo_sync_for_config(
|
||||
&plugins_config,
|
||||
Some(Arc::clone(&on_effective_plugins_changed)),
|
||||
);
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_plugin_caches_refresh(
|
||||
&config.plugins_config_input(),
|
||||
&plugins_config,
|
||||
auth,
|
||||
Some(Arc::new(move |_change| {
|
||||
Self::spawn_effective_plugins_changed_task(
|
||||
Arc::clone(&refresh_thread_manager),
|
||||
refresh_config_manager.clone(),
|
||||
);
|
||||
})),
|
||||
Some(on_effective_plugins_changed),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -188,10 +188,15 @@ fn convert_configured_marketplace_plugin_to_plugin_summary(
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static str> {
|
||||
fn remote_installed_plugin_visible_marketplaces(
|
||||
config: &Config,
|
||||
use_remote_global_catalog: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut marketplaces = Vec::new();
|
||||
if config.features.enabled(Feature::RemotePlugin) {
|
||||
if use_remote_global_catalog {
|
||||
marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME);
|
||||
}
|
||||
if config.features.enabled(Feature::RemotePlugin) {
|
||||
marketplaces.push(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME);
|
||||
}
|
||||
marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME);
|
||||
@@ -831,11 +836,14 @@ impl PluginRequestProcessor {
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
|
||||
plugins_manager.set_auth_mode(auth_mode);
|
||||
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let use_remote_global_catalog = config.features.enabled(Feature::RemotePlugin)
|
||||
&& auth_mode.is_some_and(DomainAuthMode::uses_codex_backend);
|
||||
let remote_installed_plugin_visible_marketplaces =
|
||||
remote_installed_plugin_visible_marketplaces(&config);
|
||||
remote_installed_plugin_visible_marketplaces(&config, use_remote_global_catalog);
|
||||
plugins_manager.maybe_start_remote_installed_plugin_bundle_sync(
|
||||
&plugins_input,
|
||||
auth.clone(),
|
||||
@@ -861,10 +869,7 @@ impl PluginRequestProcessor {
|
||||
)
|
||||
.await,
|
||||
);
|
||||
filter_openai_curated_installed_conflicts(
|
||||
&mut data,
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
);
|
||||
filter_openai_curated_installed_conflicts(&mut data, use_remote_global_catalog);
|
||||
|
||||
Ok(PluginInstalledResponse {
|
||||
marketplaces: data,
|
||||
|
||||
335
codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs
Normal file
335
codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::ensure;
|
||||
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::ClientRequest;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_features::Feature;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::MockServer;
|
||||
|
||||
use super::mcp_tool::TEST_SERVER_NAME;
|
||||
use super::mcp_tool::TEST_TOOL_NAME;
|
||||
use super::mcp_tool::start_mcp_server;
|
||||
|
||||
const API_CURATED_PLUGIN_NAME: &str = "api-plugin";
|
||||
const REFRESH_PROBE_SERVER_NAME: &str = "refresh-probe";
|
||||
const GITHUB_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git";
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
struct CuratedMcpSyncFixture {
|
||||
mcp: TestAppServer,
|
||||
sync_barrier: PathBuf,
|
||||
mcp_server_handle: JoinHandle<()>,
|
||||
_fixture_root: TempDir,
|
||||
_responses_server: MockServer,
|
||||
}
|
||||
|
||||
impl CuratedMcpSyncFixture {
|
||||
async fn set_up() -> Result<Self> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
|
||||
let fixture_root = TempDir::new()?;
|
||||
let codex_home = fixture_root.path().join("codex-home");
|
||||
let curated_repo = fixture_root.path().join("curated-repo");
|
||||
let git_wrapper_dir = fixture_root.path().join("bin");
|
||||
let git_config = fixture_root.path().join("gitconfig");
|
||||
let sync_barrier = fixture_root.path().join("allow-curated-sync");
|
||||
std::fs::create_dir_all(&codex_home)?;
|
||||
std::fs::create_dir_all(&curated_repo)?;
|
||||
std::fs::create_dir_all(&git_wrapper_dir)?;
|
||||
|
||||
write_curated_marketplace(&curated_repo, "marketplace.json", "openai-curated", &[])?;
|
||||
write_curated_marketplace(
|
||||
&curated_repo,
|
||||
"api_marketplace.json",
|
||||
"openai-api-curated",
|
||||
&[API_CURATED_PLUGIN_NAME],
|
||||
)?;
|
||||
write_plugin(
|
||||
&curated_repo.join("plugins").join(API_CURATED_PLUGIN_NAME),
|
||||
API_CURATED_PLUGIN_NAME,
|
||||
TEST_SERVER_NAME,
|
||||
&mcp_server_url,
|
||||
)?;
|
||||
|
||||
let real_git =
|
||||
find_executable_on_path("git").context("find git for curated sync fixture")?;
|
||||
run_git(&real_git, &curated_repo, &["init", "-b", "main"])?;
|
||||
run_git(
|
||||
&real_git,
|
||||
&curated_repo,
|
||||
&["config", "user.email", "codex-tests@openai.com"],
|
||||
)?;
|
||||
run_git(
|
||||
&real_git,
|
||||
&curated_repo,
|
||||
&["config", "user.name", "Codex Tests"],
|
||||
)?;
|
||||
run_git(&real_git, &curated_repo, &["add", "."])?;
|
||||
run_git(
|
||||
&real_git,
|
||||
&curated_repo,
|
||||
&["commit", "-m", "test curated plugins"],
|
||||
)?;
|
||||
|
||||
let curated_repo_url = format!("file://{}", curated_repo.display());
|
||||
let rewrite_key = format!("url.{curated_repo_url}.insteadOf");
|
||||
run_git(
|
||||
&real_git,
|
||||
&curated_repo,
|
||||
&[
|
||||
"config",
|
||||
"--file",
|
||||
git_config
|
||||
.to_str()
|
||||
.context("git config path should be UTF-8")?,
|
||||
&rewrite_key,
|
||||
GITHUB_PLUGINS_GIT_URL,
|
||||
],
|
||||
)?;
|
||||
|
||||
let git_wrapper = git_wrapper_dir.join("git");
|
||||
std::fs::write(
|
||||
&git_wrapper,
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "ls-remote" ]; then
|
||||
while [ ! -f "$CURATED_SYNC_BARRIER" ]; do
|
||||
sleep 0.01
|
||||
done
|
||||
fi
|
||||
exec "$REAL_GIT" "$@"
|
||||
"#,
|
||||
)?;
|
||||
let mut wrapper_permissions = std::fs::metadata(&git_wrapper)?.permissions();
|
||||
wrapper_permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_wrapper, wrapper_permissions)?;
|
||||
|
||||
MockResponsesConfig::new(&responses_server.uri())
|
||||
.enable_feature(Feature::Plugins)
|
||||
.with_root_config(&format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/""#,
|
||||
responses_server.uri()
|
||||
))
|
||||
.with_extra_config(&format!(
|
||||
r#"[plugins."{API_CURATED_PLUGIN_NAME}@openai-api-curated"]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.{REFRESH_PROBE_SERVER_NAME}]
|
||||
url = "{mcp_server_url}/mcp""#
|
||||
))
|
||||
.write(&codex_home)?;
|
||||
write_chatgpt_auth(
|
||||
&codex_home,
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let inherited_path = std::env::var_os("PATH").context("PATH should be set")?;
|
||||
let child_path = std::env::join_paths(
|
||||
std::iter::once(git_wrapper_dir).chain(std::env::split_paths(&inherited_path)),
|
||||
)?;
|
||||
let child_path = child_path.to_string_lossy();
|
||||
let real_git = real_git.to_string_lossy();
|
||||
let git_config = git_config.to_string_lossy();
|
||||
let sync_barrier_env = sync_barrier.to_string_lossy();
|
||||
let mcp = TestAppServer::builder()
|
||||
.with_codex_home(&codex_home)
|
||||
.with_env_overrides(&[
|
||||
("PATH", Some(child_path.as_ref())),
|
||||
("REAL_GIT", Some(real_git.as_ref())),
|
||||
("GIT_CONFIG_GLOBAL", Some(git_config.as_ref())),
|
||||
("CURATED_SYNC_BARRIER", Some(sync_barrier_env.as_ref())),
|
||||
])
|
||||
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
Ok(Self {
|
||||
mcp,
|
||||
sync_barrier,
|
||||
mcp_server_handle,
|
||||
_fixture_root: fixture_root,
|
||||
_responses_server: responses_server,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn existing_thread_loads_api_curated_mcp_after_auth_switch_sync() -> Result<()> {
|
||||
let mut fixture = CuratedMcpSyncFixture::set_up().await?;
|
||||
let thread_id = fixture
|
||||
.mcp
|
||||
.start_thread(ThreadStartParams::default())
|
||||
.await?
|
||||
.thread
|
||||
.id;
|
||||
// Establish the existing thread's MCP runtime while curated Git sync is still blocked.
|
||||
wait_for_mcp_starting(&mut fixture.mcp, REFRESH_PROBE_SERVER_NAME).await?;
|
||||
|
||||
let request_id = fixture
|
||||
.mcp
|
||||
.send_login_account_api_key_request("sk-test-key")
|
||||
.await?;
|
||||
let response: LoginAccountResponse =
|
||||
timeout(DEFAULT_TIMEOUT, fixture.mcp.read_response(request_id)).await??;
|
||||
assert_eq!(response, LoginAccountResponse::ApiKey {});
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
fixture
|
||||
.mcp
|
||||
.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
// The account-change refresh has completed without the API-curated bundle on disk.
|
||||
wait_for_mcp_starting(&mut fixture.mcp, REFRESH_PROBE_SERVER_NAME).await?;
|
||||
|
||||
// Let sync materialize the bundle; its completion callback must refresh this same thread.
|
||||
std::fs::write(&fixture.sync_barrier, "continue")?;
|
||||
wait_for_mcp_starting(&mut fixture.mcp, TEST_SERVER_NAME).await?;
|
||||
|
||||
let response: McpServerToolCallResponse = fixture
|
||||
.mcp
|
||||
.request(|request_id| ClientRequest::McpServerToolCall {
|
||||
request_id,
|
||||
params: McpServerToolCallParams {
|
||||
thread_id: thread_id.clone(),
|
||||
server: TEST_SERVER_NAME.to_string(),
|
||||
tool: TEST_TOOL_NAME.to_string(),
|
||||
arguments: Some(json!({"message": "available after sync"})),
|
||||
meta: None,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response.structured_content,
|
||||
Some(json!({
|
||||
"echoed": "available after sync",
|
||||
"threadId": thread_id,
|
||||
}))
|
||||
);
|
||||
|
||||
fixture.mcp_server_handle.abort();
|
||||
let _ = fixture.mcp_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_mcp_starting(mcp: &mut TestAppServer, server_name: &str) -> Result<()> {
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_matching_notification(
|
||||
"mcpServer/startupStatus/updated starting",
|
||||
|notification| {
|
||||
notification.method == "mcpServer/startupStatus/updated"
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("name"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(server_name)
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("status"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("starting")
|
||||
},
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_curated_marketplace(
|
||||
repo_root: &Path,
|
||||
manifest_name: &str,
|
||||
marketplace_name: &str,
|
||||
plugin_names: &[&str],
|
||||
) -> Result<()> {
|
||||
let manifest_root = repo_root.join(".agents/plugins");
|
||||
std::fs::create_dir_all(&manifest_root)?;
|
||||
let plugins = plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| {
|
||||
json!({
|
||||
"name": plugin_name,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": format!("./plugins/{plugin_name}"),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
manifest_root.join(manifest_name),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"name": marketplace_name,
|
||||
"plugins": plugins,
|
||||
}))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_plugin(
|
||||
root: &Path,
|
||||
plugin_name: &str,
|
||||
server_name: &str,
|
||||
mcp_server_url: &str,
|
||||
) -> Result<()> {
|
||||
std::fs::create_dir_all(root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
root.join(".codex-plugin/plugin.json"),
|
||||
serde_json::to_vec_pretty(&json!({"name": plugin_name}))?,
|
||||
)?;
|
||||
std::fs::write(
|
||||
root.join(".mcp.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"mcpServers": {
|
||||
server_name: {
|
||||
"type": "http",
|
||||
"url": format!("{mcp_server_url}/mcp"),
|
||||
},
|
||||
},
|
||||
}))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_executable_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|directory| directory.join(name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn run_git(git: &Path, cwd: &Path, args: &[&str]) -> Result<()> {
|
||||
let output = Command::new(git).current_dir(cwd).args(args).output()?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -72,8 +72,8 @@ use super::exec_server_test_support::read_exec_server_json;
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const AUTO_COMPACT_LIMIT: i64 = 1024;
|
||||
const LARGE_OUTPUT_AUTO_COMPACT_LIMIT: i64 = 1_000_000;
|
||||
const TEST_SERVER_NAME: &str = "tool_server";
|
||||
const TEST_TOOL_NAME: &str = "echo_tool";
|
||||
pub(super) const TEST_SERVER_NAME: &str = "tool_server";
|
||||
pub(super) const TEST_TOOL_NAME: &str = "echo_tool";
|
||||
const LARGE_RESPONSE_MESSAGE: &str = "large";
|
||||
const ELICITATION_TRIGGER_MESSAGE: &str = "confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
@@ -708,7 +708,7 @@ impl ServerHandler for ToolAppsMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
|
||||
pub(super) async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
|
||||
@@ -15,6 +15,8 @@ mod config_rpc;
|
||||
mod connection_handling_websocket;
|
||||
#[cfg(unix)]
|
||||
mod connection_handling_websocket_unix;
|
||||
#[cfg(unix)]
|
||||
mod curated_mcp_sync;
|
||||
mod current_time;
|
||||
mod dynamic_tools;
|
||||
mod environment_add;
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_app_server_protocol::HookTrustStatus;
|
||||
use codex_app_server_protocol::HooksListParams;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicySource;
|
||||
@@ -325,6 +326,106 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_prefers_api_curated_conflicts_after_switching_to_api_auth() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_openai_api_curated_marketplace(codex_home.path(), &["linear"])?;
|
||||
write_installed_plugin(&codex_home, "openai-api-curated", "linear")?;
|
||||
let config = format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
plugin_sharing = false
|
||||
|
||||
[plugins."linear@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
std::fs::write(codex_home.path().join("config.toml"), &config)?;
|
||||
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,
|
||||
)?;
|
||||
mount_remote_installed_plugins(
|
||||
&server,
|
||||
"GLOBAL",
|
||||
&remote_installed_plugin_body("", "1.2.3", /*enabled*/ true),
|
||||
)
|
||||
.await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
mount_empty_user_installed_plugins(&server).await;
|
||||
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let request_id = app_server
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
let response: PluginInstalledResponse =
|
||||
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
assert_eq!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| &marketplace.plugins)
|
||||
.map(|plugin| plugin.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["linear@openai-curated-remote"]
|
||||
);
|
||||
|
||||
// Keep the ChatGPT remote snapshot cached while changing auth to exercise endpoint-level
|
||||
// filtering even when the account-change cache refresh cannot run.
|
||||
std::fs::write(codex_home.path().join("config.toml"), "invalid config")?;
|
||||
let request_id = app_server
|
||||
.send_login_account_api_key_request("sk-test-key")
|
||||
.await?;
|
||||
let response: LoginAccountResponse =
|
||||
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
assert_eq!(response, LoginAccountResponse::ApiKey {});
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
app_server.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
std::fs::write(codex_home.path().join("config.toml"), config)?;
|
||||
|
||||
let request_id = app_server
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
let response: PluginInstalledResponse =
|
||||
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| &marketplace.plugins)
|
||||
.map(|plugin| plugin.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["linear@openai-api-curated"]
|
||||
);
|
||||
assert_eq!(response.marketplace_load_errors, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -2942,6 +3043,70 @@ plugins = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_chatgpt_curated_marketplace_for_bedrock_with_chatgpt_auth()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"model_provider = "amazon-bedrock"
|
||||
|
||||
[model_providers.amazon-bedrock.aws]
|
||||
region = "us-east-2"
|
||||
profile = "default"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
"#,
|
||||
)?;
|
||||
write_openai_curated_marketplace(codex_home.path(), &["chatgpt-plugin"])?;
|
||||
write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?;
|
||||
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 mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: None,
|
||||
force_refetch: false,
|
||||
})
|
||||
.await?;
|
||||
let response: PluginListResponse =
|
||||
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
||||
|
||||
let chatgpt_curated_marketplace = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| marketplace.name == "openai-curated")
|
||||
.expect("expected ChatGPT curated marketplace");
|
||||
assert_eq!(chatgpt_curated_marketplace.plugins.len(), 1);
|
||||
assert_eq!(
|
||||
chatgpt_curated_marketplace.plugins[0].id,
|
||||
"chatgpt-plugin@openai-curated"
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != "openai-api-curated")
|
||||
);
|
||||
assert!(response.marketplace_load_errors.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -34,6 +34,8 @@ use codex_rmcp_client::perform_oauth_login;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use codex_utils_cli::format_env_display;
|
||||
|
||||
use crate::plugin_cmd::load_cli_auth_mode;
|
||||
|
||||
/// Subcommands:
|
||||
/// - `list` — list configured servers (with `--json`)
|
||||
/// - `get` — show a single server (with `--json`)
|
||||
@@ -445,6 +447,12 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_mcp_manager(config: &Config) -> McpManager {
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
|
||||
plugins_manager.set_auth_mode(load_cli_auth_mode(config).await);
|
||||
McpManager::new(plugins_manager)
|
||||
}
|
||||
|
||||
async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> {
|
||||
let overrides = config_overrides
|
||||
.parse_overrides()
|
||||
@@ -452,9 +460,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
let config = Config::load_with_cli_overrides(overrides)
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
)));
|
||||
let mcp_manager = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
|
||||
let LoginArgs { name, scopes } = login_args;
|
||||
@@ -507,9 +513,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
|
||||
let config = Config::load_with_cli_overrides(overrides)
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
)));
|
||||
let mcp_manager = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
|
||||
let LogoutArgs { name } = logout_args;
|
||||
@@ -544,9 +548,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
let config = Config::load_with_cli_overrides(overrides)
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
)));
|
||||
let mcp_manager = load_mcp_manager(&config).await;
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
|
||||
let auth = auth_manager.auth().await;
|
||||
@@ -810,9 +812,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
|
||||
let config = Config::load_with_cli_overrides(overrides)
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
)));
|
||||
let mcp_manager = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
|
||||
let Some(server) = mcp_servers.get(&get_args.name) else {
|
||||
|
||||
@@ -9,6 +9,7 @@ use anyhow::Result;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::load_global_mcp_servers;
|
||||
use codex_login::CODEX_API_KEY_ENV_VAR;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -57,6 +58,58 @@ fn list_shows_empty_state() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_auth_exposes_api_curated_plugin_mcp_servers() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
|
||||
[plugins."api-docs@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-api-curated/api-docs/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"api-docs","version":"local"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"api-docs": {
|
||||
"type": "stdio",
|
||||
"command": "api-docs-mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
let mut list_cmd = codex_command(codex_home.path())?;
|
||||
list_cmd
|
||||
.env(CODEX_API_KEY_ENV_VAR, "sk-test")
|
||||
.args(["mcp", "list", "--json"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains(r#""name": "api-docs""#));
|
||||
|
||||
let mut get_cmd = codex_command(codex_home.path())?;
|
||||
get_cmd
|
||||
.env(CODEX_API_KEY_ENV_VAR, "sk-test")
|
||||
.args(["mcp", "get", "api-docs", "--json"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains(r#""name": "api-docs""#));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_discovers_local_oauth_server_through_environment_proxy() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -76,6 +76,14 @@ pub struct PluginHookLoadOutcome {
|
||||
pub hook_load_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The built-in curated marketplace selection for the current runtime.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetCuratedMarketplace {
|
||||
OpenAi,
|
||||
OpenAiWithRemote,
|
||||
OpenAiApi,
|
||||
}
|
||||
|
||||
enum PluginLoadScope<'a> {
|
||||
AllCapabilities {
|
||||
restriction_product: Option<Product>,
|
||||
@@ -192,9 +200,10 @@ pub async fn load_plugin_hooks_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
extra_plugins: HashMap<String, PluginConfig>,
|
||||
store: &PluginStore,
|
||||
target_curated_marketplace: TargetCuratedMarketplace,
|
||||
remote_global_catalog_active: bool,
|
||||
) -> PluginHookLoadOutcome {
|
||||
let plugins = load_plugins_from_layer_stack_with_scope(
|
||||
let mut plugins = load_plugins_from_layer_stack_with_scope(
|
||||
config_layer_stack,
|
||||
extra_plugins,
|
||||
store,
|
||||
@@ -202,6 +211,9 @@ pub async fn load_plugin_hooks_from_layer_stack(
|
||||
PluginLoadScope::HooksOnly,
|
||||
)
|
||||
.await;
|
||||
plugins.retain(|plugin| {
|
||||
plugin_is_eligible_for_target_marketplace(&plugin.config_name, target_curated_marketplace)
|
||||
});
|
||||
PluginHookLoadOutcome {
|
||||
hook_sources: plugins
|
||||
.iter()
|
||||
@@ -236,7 +248,7 @@ fn merge_configured_plugins_with_remote_installed(
|
||||
let Ok(plugin_id) = PluginId::parse(plugin_key) else {
|
||||
continue;
|
||||
};
|
||||
if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name)
|
||||
if plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME
|
||||
|| store.active_plugin_version(&plugin_id).is_none()
|
||||
{
|
||||
continue;
|
||||
@@ -267,6 +279,28 @@ fn merge_configured_plugins_with_remote_installed(
|
||||
configured_plugins
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_is_eligible_for_target_marketplace(
|
||||
plugin_key: &str,
|
||||
target_curated_marketplace: TargetCuratedMarketplace,
|
||||
) -> bool {
|
||||
let Ok(plugin_id) = PluginId::parse(plugin_key) else {
|
||||
return true;
|
||||
};
|
||||
match target_curated_marketplace {
|
||||
TargetCuratedMarketplace::OpenAi => {
|
||||
plugin_id.marketplace_name != crate::OPENAI_API_CURATED_MARKETPLACE_NAME
|
||||
&& plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME
|
||||
}
|
||||
TargetCuratedMarketplace::OpenAiWithRemote => {
|
||||
plugin_id.marketplace_name != crate::OPENAI_API_CURATED_MARKETPLACE_NAME
|
||||
}
|
||||
TargetCuratedMarketplace::OpenAiApi => {
|
||||
plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME
|
||||
&& plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_plugin_name_for_marketplace(
|
||||
plugin_key: &str,
|
||||
marketplace_name: &str,
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::TargetCuratedMarketplace;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::load_plugin_apps_from_manifest;
|
||||
@@ -15,6 +16,7 @@ use crate::loader::load_plugins_from_layer_stack;
|
||||
use crate::loader::log_plugin_load_errors;
|
||||
use crate::loader::materialize_marketplace_plugin_source;
|
||||
use crate::loader::plugin_capability_summary_from_root;
|
||||
use crate::loader::plugin_is_eligible_for_target_marketplace;
|
||||
use crate::loader::refresh_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_detailed;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed;
|
||||
@@ -484,6 +486,26 @@ impl PluginLoadCacheKey {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_curated_marketplace(
|
||||
auth_mode: Option<AuthMode>,
|
||||
model_provider_id: &str,
|
||||
) -> TargetCuratedMarketplace {
|
||||
match auth_mode {
|
||||
Some(auth_mode) => {
|
||||
if auth_mode.uses_codex_backend() {
|
||||
TargetCuratedMarketplace::OpenAiWithRemote
|
||||
} else {
|
||||
TargetCuratedMarketplace::OpenAiApi
|
||||
}
|
||||
}
|
||||
// Bedrock can use ambient AWS credentials without producing a stored auth mode.
|
||||
None if model_provider_id == AMAZON_BEDROCK_PROVIDER_ID => {
|
||||
TargetCuratedMarketplace::OpenAiApi
|
||||
}
|
||||
None => TargetCuratedMarketplace::OpenAi,
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginsManager {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self::new_with_options(codex_home, Some(Product::Codex), /*auth_mode*/ None)
|
||||
@@ -562,6 +584,20 @@ impl PluginsManager {
|
||||
config.remote_plugin_enabled && self.auth_mode().is_some_and(AuthMode::uses_codex_backend)
|
||||
}
|
||||
|
||||
/// Starts the local curated marketplace sync when the remote catalog is unavailable.
|
||||
pub fn maybe_start_curated_repo_sync_for_config(
|
||||
self: &Arc<Self>,
|
||||
config: &PluginsConfigInput,
|
||||
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
|
||||
) {
|
||||
if config.plugins_enabled && !self.remote_global_catalog_active(config) {
|
||||
self.start_curated_repo_sync(
|
||||
config.http_client_factory.clone(),
|
||||
on_effective_plugins_changed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) {
|
||||
let mut stored_client = match self.analytics_events_client.write() {
|
||||
Ok(client_guard) => client_guard,
|
||||
@@ -633,7 +669,7 @@ impl PluginsManager {
|
||||
remote_global_catalog_active,
|
||||
);
|
||||
if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
|
||||
return self.resolve_loaded_plugins_for_auth(plugins);
|
||||
return self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id);
|
||||
}
|
||||
|
||||
let Ok(_load_permit) = self.loaded_plugins_load_semaphore.acquire().await else {
|
||||
@@ -641,7 +677,7 @@ impl PluginsManager {
|
||||
return PluginLoadOutcome::default();
|
||||
};
|
||||
if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
|
||||
return self.resolve_loaded_plugins_for_auth(plugins);
|
||||
return self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id);
|
||||
}
|
||||
let cache_generation = self.loaded_plugins_cache_generation();
|
||||
let plugin_skill_snapshots = PluginSkillSnapshots::for_plugin_load();
|
||||
@@ -662,11 +698,22 @@ impl PluginsManager {
|
||||
plugins.clone(),
|
||||
plugin_skill_snapshots,
|
||||
);
|
||||
self.resolve_loaded_plugins_for_auth(plugins)
|
||||
self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id)
|
||||
}
|
||||
|
||||
fn resolve_loaded_plugins_for_auth(&self, mut plugins: Vec<LoadedPlugin>) -> PluginLoadOutcome {
|
||||
fn resolve_loaded_plugins_for_auth(
|
||||
&self,
|
||||
mut plugins: Vec<LoadedPlugin>,
|
||||
model_provider_id: &str,
|
||||
) -> PluginLoadOutcome {
|
||||
let auth_mode = self.auth_mode();
|
||||
let target_curated_marketplace = target_curated_marketplace(auth_mode, model_provider_id);
|
||||
plugins.retain(|plugin| {
|
||||
plugin_is_eligible_for_target_marketplace(
|
||||
&plugin.config_name,
|
||||
target_curated_marketplace,
|
||||
)
|
||||
});
|
||||
for plugin in &mut plugins {
|
||||
let plugin_active = plugin.is_active();
|
||||
apply_app_mcp_routing_policy(
|
||||
@@ -714,9 +761,13 @@ impl PluginsManager {
|
||||
fn clear_caches_after_marketplace_source_refresh(
|
||||
&self,
|
||||
installed_plugin_cache_refreshed: bool,
|
||||
on_effective_plugins_changed: Option<&EffectivePluginsChangedCallback>,
|
||||
) {
|
||||
if installed_plugin_cache_refreshed {
|
||||
self.clear_cache();
|
||||
if let Some(on_effective_plugins_changed) = on_effective_plugins_changed {
|
||||
on_effective_plugins_changed(EffectivePluginsChange::default());
|
||||
}
|
||||
} else {
|
||||
self.tool_suggest_metadata_cache.clear();
|
||||
}
|
||||
@@ -741,7 +792,7 @@ impl PluginsManager {
|
||||
Arc::clone(&self.skill_root_scan_slots),
|
||||
)
|
||||
.await;
|
||||
self.resolve_loaded_plugins_for_auth(plugins)
|
||||
self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id)
|
||||
}
|
||||
|
||||
/// Resolve plugin hooks for a config layer stack without loading other plugin capabilities.
|
||||
@@ -753,10 +804,13 @@ impl PluginsManager {
|
||||
if !config.plugins_enabled {
|
||||
return PluginHookLoadOutcome::default();
|
||||
}
|
||||
let target_curated_marketplace =
|
||||
target_curated_marketplace(self.auth_mode(), &config.model_provider_id);
|
||||
load_plugin_hooks_from_layer_stack(
|
||||
config_layer_stack,
|
||||
self.remote_installed_plugin_configs(),
|
||||
&self.store,
|
||||
target_curated_marketplace,
|
||||
self.remote_global_catalog_active(config),
|
||||
)
|
||||
.await
|
||||
@@ -2049,11 +2103,10 @@ impl PluginsManager {
|
||||
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
|
||||
) {
|
||||
if config.plugins_enabled {
|
||||
let use_remote_global_catalog =
|
||||
config.remote_plugin_enabled && auth_manager.current_auth_uses_codex_backend();
|
||||
if !use_remote_global_catalog {
|
||||
self.start_curated_repo_sync(config.http_client_factory.clone());
|
||||
}
|
||||
self.maybe_start_curated_repo_sync_for_config(
|
||||
config,
|
||||
on_effective_plugins_changed.clone(),
|
||||
);
|
||||
let should_spawn_marketplace_auto_upgrade = {
|
||||
let mut state = match self.configured_marketplace_upgrade_state.write() {
|
||||
Ok(state) => state,
|
||||
@@ -2189,6 +2242,7 @@ impl PluginsManager {
|
||||
Ok(refresh_outcome) => {
|
||||
self.clear_caches_after_marketplace_source_refresh(
|
||||
refresh_outcome.cache_refreshed,
|
||||
/*on_effective_plugins_changed*/ None,
|
||||
);
|
||||
outcome
|
||||
.errors
|
||||
@@ -2517,10 +2571,27 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_curated_repo_sync(self: &Arc<Self>, http_client_factory: HttpClientFactory) {
|
||||
fn start_curated_repo_sync(
|
||||
self: &Arc<Self>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
|
||||
) {
|
||||
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let on_effective_plugins_changed =
|
||||
on_effective_plugins_changed.map(|on_effective_plugins_changed| {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return on_effective_plugins_changed;
|
||||
};
|
||||
let callback: EffectivePluginsChangedCallback = Arc::new(move |change| {
|
||||
let on_effective_plugins_changed = Arc::clone(&on_effective_plugins_changed);
|
||||
runtime.spawn(async move {
|
||||
on_effective_plugins_changed(change);
|
||||
});
|
||||
});
|
||||
callback
|
||||
});
|
||||
let manager = Arc::clone(self);
|
||||
let codex_home = self.codex_home.clone();
|
||||
if let Err(err) = std::thread::Builder::new()
|
||||
@@ -2536,8 +2607,10 @@ impl PluginsManager {
|
||||
&configured_curated_plugin_ids,
|
||||
) {
|
||||
Ok(cache_refreshed) => {
|
||||
manager
|
||||
.clear_caches_after_marketplace_source_refresh(cache_refreshed);
|
||||
manager.clear_caches_after_marketplace_source_refresh(
|
||||
cache_refreshed,
|
||||
on_effective_plugins_changed.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
manager.clear_cache();
|
||||
@@ -2800,20 +2873,18 @@ impl PluginsManager {
|
||||
self.codex_home.as_path(),
|
||||
));
|
||||
let curated_marketplace_path = if include_openai_curated {
|
||||
if config.model_provider_id == AMAZON_BEDROCK_PROVIDER_ID
|
||||
|| matches!(
|
||||
self.auth_mode(),
|
||||
Some(AuthMode::ApiKey | AuthMode::BedrockApiKey)
|
||||
)
|
||||
{
|
||||
let api_marketplace_path =
|
||||
curated_plugins_api_marketplace_path(self.codex_home.as_path());
|
||||
api_marketplace_path
|
||||
.is_file()
|
||||
.then_some(api_marketplace_path)
|
||||
} else {
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
curated_repo_root.is_dir().then_some(curated_repo_root)
|
||||
match target_curated_marketplace(self.auth_mode(), &config.model_provider_id) {
|
||||
TargetCuratedMarketplace::OpenAi | TargetCuratedMarketplace::OpenAiWithRemote => {
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
curated_repo_root.is_dir().then_some(curated_repo_root)
|
||||
}
|
||||
TargetCuratedMarketplace::OpenAiApi => {
|
||||
let api_marketplace_path =
|
||||
curated_plugins_api_marketplace_path(self.codex_home.as_path());
|
||||
api_marketplace_path
|
||||
.is_file()
|
||||
.then_some(api_marketplace_path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -138,6 +138,54 @@ fn plugins_manager_tracks_auth_mode() {
|
||||
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_repo_sync_stays_deferred_for_remote_chatgpt_catalog() {
|
||||
CURATED_REPO_SYNC_STARTED.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = PluginsConfigInput::new(
|
||||
unrestricted_config_layer_stack(),
|
||||
"openai".to_string(),
|
||||
/*plugins_enabled*/ true,
|
||||
/*remote_plugin_enabled*/ true,
|
||||
"https://chatgpt.com".to_string(),
|
||||
test_http_client_factory(),
|
||||
);
|
||||
let manager = Arc::new(PluginsManager::new_with_options(
|
||||
tmp.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::Chatgpt),
|
||||
));
|
||||
|
||||
manager.maybe_start_curated_repo_sync_for_config(
|
||||
&config, /*on_effective_plugins_changed*/ None,
|
||||
);
|
||||
|
||||
assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_source_refresh_notifies_only_after_installed_cache_changes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let callback_count_for_callback = Arc::clone(&callback_count);
|
||||
let callback: EffectivePluginsChangedCallback = Arc::new(move |_change| {
|
||||
callback_count_for_callback.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
|
||||
manager.clear_caches_after_marketplace_source_refresh(
|
||||
/*installed_plugin_cache_refreshed*/ false,
|
||||
Some(&callback),
|
||||
);
|
||||
assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 0);
|
||||
|
||||
manager.clear_caches_after_marketplace_source_refresh(
|
||||
/*installed_plugin_cache_refreshed*/ true,
|
||||
Some(&callback),
|
||||
);
|
||||
assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() {
|
||||
let codex_home = TempDir::new().expect("create Codex home");
|
||||
@@ -805,6 +853,16 @@ fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: &
|
||||
);
|
||||
}
|
||||
|
||||
async fn loaded_plugin_names(manager: &PluginsManager, config: &PluginsConfigInput) -> Vec<String> {
|
||||
manager
|
||||
.plugins_for_config(config)
|
||||
.await
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -1286,27 +1344,30 @@ enabled = true
|
||||
|
||||
[plugins."calendar@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."linear@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
|
||||
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let manager = PluginsManager::new_with_options(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::Chatgpt),
|
||||
);
|
||||
manager.write_remote_installed_plugins_cache(vec![
|
||||
remote_installed_plugin("linear"),
|
||||
remote_installed_plugin("remote-only"),
|
||||
]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(
|
||||
outcome
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec![
|
||||
"calendar@openai-curated".to_string(),
|
||||
"linear@openai-curated".to_string(),
|
||||
@@ -1315,6 +1376,36 @@ enabled = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_curated_plugin_does_not_suppress_remote_curated_conflict_for_chatgpt() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
|
||||
[plugins."linear@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new_with_options(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::Chatgpt),
|
||||
);
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
|
||||
|
||||
assert_eq!(
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["linear@openai-curated-remote".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_global_catalog_ignores_local_curated_plugins() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -1350,15 +1441,9 @@ enabled = true
|
||||
remote_installed_plugin("remote-only"),
|
||||
]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(
|
||||
outcome
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec![
|
||||
"linear@openai-api-curated".to_string(),
|
||||
"linear@openai-curated-remote".to_string(),
|
||||
"remote-only@openai-curated-remote".to_string(),
|
||||
]
|
||||
@@ -1366,7 +1451,7 @@ enabled = true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_plugin_feature_keeps_local_curated_without_codex_backend() {
|
||||
async fn non_chatgpt_auth_rejects_cached_remote_curated_plugins_after_auth_switch() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -1382,26 +1467,46 @@ enabled = true
|
||||
);
|
||||
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let mut config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new_with_options(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::ApiKey),
|
||||
Some(AuthMode::Chatgpt),
|
||||
);
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]);
|
||||
assert_eq!(
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["remote-only@openai-curated-remote".to_string()]
|
||||
);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
|
||||
manager.set_auth_mode(/*auth_mode*/ None);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"linear@openai-api-curated".to_string(),
|
||||
"linear@openai-curated".to_string(),
|
||||
]
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["linear@openai-curated".to_string()]
|
||||
);
|
||||
|
||||
for auth_mode in [AuthMode::ApiKey, AuthMode::BedrockApiKey] {
|
||||
manager.set_auth_mode(Some(auth_mode));
|
||||
|
||||
assert_eq!(
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["linear@openai-api-curated".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
manager.set_auth_mode(/*auth_mode*/ None);
|
||||
config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string();
|
||||
assert_eq!(
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["linear@openai-api-curated".to_string()]
|
||||
);
|
||||
|
||||
manager.set_auth_mode(Some(AuthMode::Chatgpt));
|
||||
assert_eq!(
|
||||
loaded_plugin_names(&manager, &config).await,
|
||||
vec!["remote-only@openai-curated-remote".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2715,7 +2820,11 @@ enabled = true
|
||||
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_persisted")
|
||||
.expect("persist remote plugin id");
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let manager = PluginsManager::new_with_options(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::Chatgpt),
|
||||
);
|
||||
if let Some(installed_plugins) = installed_plugins {
|
||||
manager.write_remote_installed_plugins_cache(installed_plugins);
|
||||
}
|
||||
@@ -4527,6 +4636,42 @@ plugins = true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_uses_chatgpt_curated_manifest_for_bedrock_with_chatgpt_auth() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"model_provider = "amazon-bedrock"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]);
|
||||
write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]);
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
manager.set_auth_mode(Some(AuthMode::Chatgpt));
|
||||
let marketplaces = manager
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert!(
|
||||
marketplaces
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
);
|
||||
assert!(
|
||||
marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != OPENAI_API_CURATED_MARKETPLACE_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_skips_missing_api_curated_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -6158,6 +6303,97 @@ async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() {
|
||||
assert_eq!(outcome.hook_load_warnings, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_hooks_for_layer_stack_follow_auth_mode_and_provider() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"model_provider = "amazon-bedrock"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."linear@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
for marketplace_name in [
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
OPENAI_API_CURATED_MARKETPLACE_NAME,
|
||||
] {
|
||||
write_cached_plugin(codex_home.path(), marketplace_name, "linear");
|
||||
write_file(
|
||||
&codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace_name)
|
||||
.join("linear/local/hooks/hooks.json"),
|
||||
r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo startup"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
}
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new_with_options(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some(Product::Codex),
|
||||
Some(AuthMode::Chatgpt),
|
||||
);
|
||||
|
||||
let chatgpt_hooks = manager
|
||||
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
chatgpt_hooks
|
||||
.hook_sources
|
||||
.iter()
|
||||
.map(|source| source.plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["linear@openai-curated"]
|
||||
);
|
||||
|
||||
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
||||
let api_hooks = manager
|
||||
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
api_hooks
|
||||
.hook_sources
|
||||
.iter()
|
||||
.map(|source| source.plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["linear@openai-api-curated"]
|
||||
);
|
||||
|
||||
assert!(manager.set_auth_mode(/*auth_mode*/ None));
|
||||
let bedrock_hooks = manager
|
||||
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
|
||||
.await;
|
||||
assert_eq!(
|
||||
bedrock_hooks
|
||||
.hook_sources
|
||||
.iter()
|
||||
.map(|source| source.plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["linear@openai-api-curated"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_installed_plugins_cache_refresh_coalesces_materializations() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -10,7 +10,10 @@ use codex_core_plugins::store::PluginStore;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::auth::AuthMode;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -40,6 +43,7 @@ use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
@@ -308,6 +312,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context(
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&codex_home))
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_model("gpt-5.2");
|
||||
let test_codex = builder.build_with_auto_env(&server).await?;
|
||||
let codex = Arc::clone(&test_codex.codex);
|
||||
@@ -435,6 +440,260 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_turns_route_curated_plugin_skills_after_auth_switch() -> Result<()> {
|
||||
const CHATGPT_CURATED_PLUGIN_SKILL: &str = "chatgpt-plugin:chatgpt-skill";
|
||||
const API_CURATED_PLUGIN_SKILL: &str = "api-plugin:api-skill";
|
||||
const CURATED_PLUGIN_SKILLS: &[&str] =
|
||||
&[CHATGPT_CURATED_PLUGIN_SKILL, API_CURATED_PLUGIN_SKILL];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TargetAuth {
|
||||
Chatgpt,
|
||||
ApiKey,
|
||||
BedrockApiKey,
|
||||
NoCodexAuth,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Fixture {
|
||||
name: &'static str,
|
||||
target_auth: TargetAuth,
|
||||
target_model_provider_id: &'static str,
|
||||
target_prompt: &'static str,
|
||||
expected_target_loaded_plugin_skills: &'static [&'static str],
|
||||
expected_target_skill_description: &'static str,
|
||||
}
|
||||
|
||||
const FIXTURES: &[Fixture] = &[
|
||||
Fixture {
|
||||
name: "ChatGPT",
|
||||
target_auth: TargetAuth::Chatgpt,
|
||||
target_model_provider_id: OPENAI_PROVIDER_ID,
|
||||
target_prompt: "chatgpt target turn",
|
||||
expected_target_loaded_plugin_skills: &[CHATGPT_CURATED_PLUGIN_SKILL],
|
||||
expected_target_skill_description: "chatgpt description",
|
||||
},
|
||||
Fixture {
|
||||
name: "API key",
|
||||
target_auth: TargetAuth::ApiKey,
|
||||
target_model_provider_id: OPENAI_PROVIDER_ID,
|
||||
target_prompt: "api key target turn",
|
||||
expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL],
|
||||
expected_target_skill_description: "api description before",
|
||||
},
|
||||
Fixture {
|
||||
name: "Bedrock API key",
|
||||
target_auth: TargetAuth::BedrockApiKey,
|
||||
target_model_provider_id: AMAZON_BEDROCK_PROVIDER_ID,
|
||||
target_prompt: "bedrock key target turn",
|
||||
expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL],
|
||||
expected_target_skill_description: "api description before",
|
||||
},
|
||||
Fixture {
|
||||
name: "ambient Bedrock",
|
||||
target_auth: TargetAuth::NoCodexAuth,
|
||||
target_model_provider_id: AMAZON_BEDROCK_PROVIDER_ID,
|
||||
target_prompt: "ambient bedrock target turn",
|
||||
expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL],
|
||||
expected_target_skill_description: "api description before",
|
||||
},
|
||||
];
|
||||
|
||||
async fn skills_for_agent_turn(
|
||||
test_codex: &TestCodex,
|
||||
response: &ResponseMock,
|
||||
model_provider_id: &str,
|
||||
prompt: &str,
|
||||
expected_request_count: usize,
|
||||
) -> Result<String> {
|
||||
let mut config = test_codex.config.clone();
|
||||
config.model_provider_id = model_provider_id.to_string();
|
||||
let thread = test_codex
|
||||
.thread_manager
|
||||
.start_thread(codex_core::StartThreadOptions::new(config))
|
||||
.await?
|
||||
.thread;
|
||||
thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: prompt.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let requests = response.requests();
|
||||
assert_eq!(requests.len(), expected_request_count);
|
||||
Ok(requests
|
||||
.last()
|
||||
.expect("agent turn should send a request")
|
||||
.message_input_text_groups("developer")
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|texts| texts.iter().any(|text| text.contains("## Skills")))
|
||||
.expect("agent turn should include a skills developer message")
|
||||
.join("\n"))
|
||||
}
|
||||
|
||||
skip_if_no_network!(Ok(()));
|
||||
let assert_loaded_plugin_skills =
|
||||
|fixture_name: &str, phase: &str, skills: &str, expected: &[&str]| {
|
||||
let loaded_plugin_skills = CURATED_PLUGIN_SKILLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|plugin_skill| skills.contains(plugin_skill))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
loaded_plugin_skills.as_slice(),
|
||||
expected,
|
||||
"unexpected curated plugin skills for {fixture_name} during {phase}: {skills:?}"
|
||||
);
|
||||
};
|
||||
|
||||
for fixture in FIXTURES {
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
ev_completed("resp-initial"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-target"),
|
||||
ev_completed("resp-target"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
|
||||
[plugins."chatgpt-plugin@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."api-plugin@openai-api-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
for (marketplace_name, plugin_name, skill_name, description) in [
|
||||
(
|
||||
"openai-curated",
|
||||
"chatgpt-plugin",
|
||||
"chatgpt-skill",
|
||||
"chatgpt description",
|
||||
),
|
||||
(
|
||||
"openai-api-curated",
|
||||
"api-plugin",
|
||||
"api-skill",
|
||||
"api description before",
|
||||
),
|
||||
] {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace_name)
|
||||
.join(plugin_name)
|
||||
.join("local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}","description":"{plugin_name}"}}"#),
|
||||
)?;
|
||||
let skill_dir = plugin_root.join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\ndescription: {description}\n---\n\n# body\n"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&codex_home))
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let test_codex = builder.build_with_auto_env(&server).await?;
|
||||
let plugins_manager = test_codex.thread_manager.plugins_manager();
|
||||
let skills_service = test_codex.thread_manager.skills_service();
|
||||
|
||||
let initial_skills = skills_for_agent_turn(
|
||||
&test_codex,
|
||||
&response,
|
||||
OPENAI_PROVIDER_ID,
|
||||
"initial chatgpt turn",
|
||||
/*expected_request_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
assert_loaded_plugin_skills(
|
||||
fixture.name,
|
||||
"initial ChatGPT turn",
|
||||
&initial_skills,
|
||||
&[CHATGPT_CURATED_PLUGIN_SKILL],
|
||||
);
|
||||
assert!(initial_skills.contains("chatgpt description"));
|
||||
|
||||
std::fs::write(
|
||||
codex_home.path().join(
|
||||
"plugins/cache/openai-api-curated/api-plugin/local/skills/api-skill/SKILL.md",
|
||||
),
|
||||
"---\ndescription: api description after\n---\n\n# body\n",
|
||||
)?;
|
||||
|
||||
match fixture.target_auth {
|
||||
TargetAuth::Chatgpt => {}
|
||||
TargetAuth::ApiKey => {
|
||||
plugins_manager.set_auth_mode(Some(AuthMode::ApiKey));
|
||||
}
|
||||
TargetAuth::BedrockApiKey => {
|
||||
plugins_manager.set_auth_mode(Some(AuthMode::BedrockApiKey));
|
||||
}
|
||||
TargetAuth::NoCodexAuth => {
|
||||
test_codex.thread_manager.auth_manager().logout().await?;
|
||||
assert_eq!(
|
||||
test_codex.thread_manager.auth_manager().get_api_auth_mode(),
|
||||
None
|
||||
);
|
||||
plugins_manager.set_auth_mode(/*auth_mode*/ None);
|
||||
}
|
||||
}
|
||||
skills_service.clear_cache();
|
||||
let target_skills = skills_for_agent_turn(
|
||||
&test_codex,
|
||||
&response,
|
||||
fixture.target_model_provider_id,
|
||||
fixture.target_prompt,
|
||||
/*expected_request_count*/ 2,
|
||||
)
|
||||
.await?;
|
||||
assert_loaded_plugin_skills(
|
||||
fixture.name,
|
||||
"target turn",
|
||||
&target_skills,
|
||||
fixture.expected_target_loaded_plugin_skills,
|
||||
);
|
||||
assert!(
|
||||
target_skills.contains(fixture.expected_target_skill_description),
|
||||
"expected {:?} in current skills: {skills:?}",
|
||||
fixture.expected_target_skill_description,
|
||||
skills = target_skills
|
||||
);
|
||||
assert!(!target_skills.contains("api description after"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user