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:
felixxia-oai
2026-07-27 21:17:42 +00:00
committed by copyberry
parent fd41e813cb
commit 294d813263
12 changed files with 1258 additions and 88 deletions
+259
View File
@@ -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(()));