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
7 changed files with 444 additions and 13 deletions

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();