feat: load plugin apps (#13401)

load plugin-apps from `.app.json`.

make apps runtime-mentionable iff `codex_apps` MCP actually exposes
tools for that `connector_id`.

if the app isn't available, it's filtered out of runtime connector set,
so no tools are added and no app-mentions resolve.

right now we don't have a clean cli-side error for an app not being
installed. can look at this after.

### Tests
Added tests, tested locally that using a plugin that bundles an app
picks up the app.
This commit is contained in:
sayan-oai 2026-03-03 16:29:15 -08:00 committed by GitHub
parent c4cb594e73
commit 082682a628
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 444 additions and 13 deletions

View file

@ -26,7 +26,9 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_o
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
use codex_core::connectors::merge_connectors;
use codex_core::connectors::merge_plugin_apps;
pub use codex_core::connectors::with_app_enabled_state;
use codex_core::plugins::PluginsManager;
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
@ -106,7 +108,10 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
}
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors)
read_cached_all_connectors(&cache_key).map(|connectors| {
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
filter_disallowed_connectors(connectors)
})
}
pub async fn list_all_connectors_with_options(
@ -123,7 +128,8 @@ pub async fn list_all_connectors_with_options(
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let cache_key = all_connectors_cache_key(config, &token_data);
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
return Ok(filter_disallowed_connectors(cached_connectors));
let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config));
return Ok(filter_disallowed_connectors(connectors));
}
let mut apps = list_directory_connectors(config).await?;
@ -151,7 +157,8 @@ pub async fn list_all_connectors_with_options(
});
let connectors = filter_disallowed_connectors(connectors);
write_cached_all_connectors(cache_key, &connectors);
Ok(connectors)
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
Ok(filter_disallowed_connectors(connectors))
}
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
@ -192,6 +199,12 @@ fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[A
});
}
fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
PluginsManager::new(config.codex_home.clone())
.plugins_for_config(config)
.effective_apps()
}
pub fn merge_connectors_with_accessible(
connectors: Vec<AppInfo>,
accessible_connectors: Vec<AppInfo>,

View file

@ -4891,10 +4891,15 @@ pub(crate) async fn run_turn(
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
};
connectors::with_app_enabled_state(
let plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let connectors = connectors::merge_plugin_apps_with_accessible(
plugin_apps.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn_context.config,
)
);
connectors::with_app_enabled_state(connectors, &turn_context.config)
} else {
Vec::new()
};
@ -5614,8 +5619,16 @@ async fn built_tools(
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
let connectors = if turn_context.features.enabled(Feature::Apps) {
Some(connectors::with_app_enabled_state(
let plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let connectors = connectors::merge_plugin_apps_with_accessible(
plugin_apps.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
);
Some(connectors::with_app_enabled_state(
connectors,
&turn_context.config,
))
} else {

View file

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::sync::LazyLock;
@ -29,6 +30,7 @@ use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::token_data::TokenData;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
@ -353,6 +355,48 @@ pub fn merge_connectors(
merged
}
pub fn merge_plugin_apps(
connectors: Vec<AppInfo>,
plugin_apps: Vec<AppConnectorId>,
) -> Vec<AppInfo> {
let mut merged = connectors;
let mut connector_ids = merged
.iter()
.map(|connector| connector.id.clone())
.collect::<HashSet<_>>();
for connector_id in plugin_apps {
if connector_ids.insert(connector_id.0.clone()) {
merged.push(plugin_app_to_app_info(connector_id));
}
}
merged.sort_by(|left, right| {
right
.is_accessible
.cmp(&left.is_accessible)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
merged
}
pub fn merge_plugin_apps_with_accessible(
plugin_apps: Vec<AppConnectorId>,
accessible_connectors: Vec<AppInfo>,
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
let plugin_connectors = plugin_apps
.into_iter()
.filter(|connector_id| accessible_connector_ids.contains(connector_id.0.as_str()))
.map(plugin_app_to_app_info)
.collect::<Vec<_>>();
merge_connectors(plugin_connectors, accessible_connectors)
}
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
let apps_config = read_apps_config(config);
if let Some(apps_config) = apps_config.as_ref() {
@ -575,6 +619,28 @@ where
accessible
}
fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo {
// Leave the placeholder name as the connector id so merge_connectors() can
// replace it with canonical app metadata from directory fetches or
// connector_name values from codex_apps tool discovery.
let connector_id = connector_id.0;
let name = connector_id.clone();
AppInfo {
id: connector_id.clone(),
name: name.clone(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url(&name, &connector_id)),
is_accessible: false,
is_enabled: true,
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@ -647,6 +713,46 @@ mod tests {
}
}
#[test]
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
let accessible = AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events".to_string()),
logo_url: Some("https://example.com/logo.png".to_string()),
logo_url_dark: Some("https://example.com/logo-dark.png".to_string()),
distribution_channel: Some("workspace".to_string()),
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
};
let merged = merge_connectors(vec![plugin], vec![accessible]);
assert_eq!(
merged,
vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events".to_string()),
logo_url: Some("https://example.com/logo.png".to_string()),
logo_url_dark: Some("https://example.com/logo-dark.png".to_string()),
distribution_channel: Some("workspace".to_string()),
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url("calendar", "calendar")),
is_accessible: true,
is_enabled: true,
}]
);
assert_eq!(connector_mention_slug(&merged[0]), "google-calendar");
}
#[test]
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
let apps_config = AppsConfigToml {

View file

@ -33,6 +33,10 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
@ -42,6 +46,7 @@ pub struct LoadedPlugin {
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub apps: Vec<AppConnectorId>,
pub error: Option<String>,
}
@ -80,6 +85,21 @@ impl PluginLoadOutcome {
}
mcp_servers
}
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
let mut apps = Vec::new();
let mut seen_connector_ids = std::collections::HashSet::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for connector_id in &plugin.apps {
if seen_connector_ids.insert(connector_id.clone()) {
apps.push(connector_id.clone());
}
}
}
apps
}
}
pub struct PluginsManager {
@ -227,6 +247,18 @@ struct PluginMcpFile {
mcp_servers: HashMap<String, JsonValue>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginAppFile {
#[serde(default)]
apps: HashMap<String, PluginAppConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct PluginAppConfig {
id: String,
}
pub(crate) fn load_plugins_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
store: &PluginStore,
@ -299,6 +331,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
enabled: plugin.enabled,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
apps: Vec::new(),
error: None,
};
@ -341,6 +374,10 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
}
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.apps = load_apps_from_file(
plugin_root.as_path(),
&plugin_root.as_path().join(DEFAULT_APP_CONFIG_FILE),
);
loaded_plugin
}
@ -364,6 +401,39 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<PathBuf> {
paths
}
fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec<AppConnectorId> {
let Ok(contents) = fs::read_to_string(app_config_path) else {
return Vec::new();
};
let parsed = match serde_json::from_str::<PluginAppFile>(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warn!(
path = %app_config_path.display(),
"failed to parse plugin app config: {err}"
);
return Vec::new();
}
};
let mut apps: Vec<PluginAppConfig> = parsed.apps.into_values().collect();
apps.sort_unstable_by(|left, right| left.id.cmp(&right.id));
apps.into_iter()
.filter_map(|app| {
if app.id.trim().is_empty() {
warn!(
plugin = %plugin_root.display(),
"plugin app config is missing an app id"
);
None
} else {
Some(AppConnectorId(app.id))
}
})
.collect()
}
fn load_mcp_servers_from_file(plugin_root: &Path, mcp_config_path: &Path) -> PluginMcpDiscovery {
let Ok(contents) = fs::read_to_string(mcp_config_path) else {
return PluginMcpDiscovery::default();
@ -550,6 +620,16 @@ mod tests {
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"example": {
"id": "connector_example"
}
}
}"#,
);
let outcome =
load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()).await;
@ -582,6 +662,7 @@ mod tests {
oauth_resource: None,
},
)]),
apps: vec![AppConnectorId("connector_example".to_string())],
error: None,
}]
);
@ -590,6 +671,10 @@ mod tests {
vec![plugin_root.join("skills")]
);
assert_eq!(outcome.effective_mcp_servers().len(), 1);
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_example".to_string())]
);
}
#[tokio::test]
@ -628,6 +713,7 @@ mod tests {
enabled: false,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
apps: Vec::new(),
error: None,
}]
);
@ -635,6 +721,80 @@ mod tests {
assert!(outcome.effective_mcp_servers().is_empty());
}
#[tokio::test]
async fn effective_apps_dedupes_connector_ids_across_plugins() {
let codex_home = TempDir::new().unwrap();
let plugin_a_root = codex_home
.path()
.join("plugins/cache")
.join("test/plugin-a/local");
let plugin_b_root = codex_home
.path()
.join("plugins/cache")
.join("test/plugin-b/local");
write_file(
&plugin_a_root.join(".codex-plugin/plugin.json"),
r#"{"name":"plugin-a"}"#,
);
write_file(
&plugin_a_root.join(".app.json"),
r#"{
"apps": {
"example": {
"id": "connector_example"
}
}
}"#,
);
write_file(
&plugin_b_root.join(".codex-plugin/plugin.json"),
r#"{"name":"plugin-b"}"#,
);
write_file(
&plugin_b_root.join(".app.json"),
r#"{
"apps": {
"chat": {
"id": "connector_example"
},
"gmail": {
"id": "connector_gmail"
}
}
}"#,
);
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugins = toml::map::Map::new();
let mut plugin_a = toml::map::Map::new();
plugin_a.insert("enabled".to_string(), Value::Boolean(true));
plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a));
let mut plugin_b = toml::map::Map::new();
plugin_b.insert("enabled".to_string(), Value::Boolean(true));
plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b));
root.insert("plugins".to_string(), Value::Table(plugins));
let config_toml =
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize");
let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await;
assert_eq!(
outcome.effective_apps(),
vec![
AppConnectorId("connector_example".to_string()),
AppConnectorId("connector_gmail".to_string()),
]
);
}
#[test]
fn plugin_namespace_for_skill_path_uses_manifest_name() {
let codex_home = TempDir::new().unwrap();

View file

@ -2,6 +2,7 @@ mod manager;
mod manifest;
mod store;
pub use manager::AppConnectorId;
pub use manager::LoadedPlugin;
pub use manager::PluginInstallError;
pub use manager::PluginLoadOutcome;

View file

@ -23,8 +23,15 @@ pub struct AppsTestServer {
impl AppsTestServer {
pub async fn mount(server: &MockServer) -> Result<Self> {
Self::mount_with_connector_name(server, CONNECTOR_NAME).await
}
pub async fn mount_with_connector_name(
server: &MockServer,
connector_name: &str,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_streamable_http_json_rpc(server).await;
mount_streamable_http_json_rpc(server, connector_name.to_string()).await;
Ok(Self {
chatgpt_base_url: server.uri(),
})
@ -43,15 +50,17 @@ async fn mount_oauth_metadata(server: &MockServer) {
.await;
}
async fn mount_streamable_http_json_rpc(server: &MockServer) {
async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
.respond_with(CodexAppsJsonRpcResponder)
.respond_with(CodexAppsJsonRpcResponder { connector_name })
.mount(server)
.await;
}
struct CodexAppsJsonRpcResponder;
struct CodexAppsJsonRpcResponder {
connector_name: String,
}
impl Respond for CodexAppsJsonRpcResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
@ -117,7 +126,7 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": CONNECTOR_NAME
"connector_name": self.connector_name.clone()
}
},
{
@ -133,7 +142,7 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": CONNECTOR_NAME
"connector_name": self.connector_name.clone()
}
}
],

View file

@ -7,11 +7,14 @@ use std::time::Instant;
use anyhow::Result;
use codex_core::CodexAuth;
use codex_core::features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@ -74,6 +77,35 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
.expect("write config");
}
fn write_plugin_app_plugin(home: &TempDir) {
let plugin_root = home.path().join("plugins/sample");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "calendar"
}
}
}"#,
)
.expect("write plugin app config");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
}
async fn build_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
@ -88,6 +120,23 @@ async fn build_plugin_test_codex(
.codex)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
.map(|tools| {
tools
.iter()
.filter_map(|tool| {
tool.get("name")
.or_else(|| tool.get("type"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_skills_append_to_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
@ -138,6 +187,86 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let codex_home = Arc::new(TempDir::new()?);
write_plugin_app_plugin(codex_home.as_ref());
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.features.enable(Feature::Apps);
config.features.disable(Feature::AppsMcpGateway);
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "Use $google-calendar and then call tools.".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let first_tools = tool_names(&requests[0].body_json());
assert!(
!first_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"app tools should stay hidden before plugin app mention: {first_tools:?}"
);
let second_tools = tool_names(&requests[1].body_json());
assert!(
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"calendar create tool should be available after plugin app mention: {second_tools:?}"
);
assert!(
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
"calendar list tool should be available after plugin app mention: {second_tools:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn plugin_mcp_tools_are_listed() -> Result<()> {
skip_if_no_network!(Ok(()));