mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Make plugin details capability aware (#27958)
## Summary
Makes plugin details/read flows capability-aware so auth-filtered plugin
surfaces report the same usable app/MCP/skill shape as the marketplace
and install flows.
## Validation
Not run; this change was rebased onto the current plugin auth stack and
pushed as a draft PR.
**Manual test**
1. set up a local marketplace with a plugin that has both app and mcp
declarations
```
// .app.json
{
"apps": {
"linear": {
"id": "some_id"
}
}
}
```
```
// .mcp.json
{
"mcpServers": {
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp",
"oauth_resource": "https://mcp.linear.app/mcp"
},
"linear2": {
"type": "http",
"url": "https://mcp.linear2.app/mcp",
"oauth_resource": "https://mcp.linear2.app/mcp"
}
}
}
```
2a. **login in with api key** and observe plugin details page which
shows no apps (note we don't show "app not available due to api key log
in as there's no way to differentiate between no apps and app without
substitute mcp exists" without significantly more code changes, i've
separated this to a follow up if we want that behaviour.
<img width="1170" height="279" alt="Screenshot 2026-06-15 at 23 45 40"
src="https://github.com/user-attachments/assets/d36cb160-fbec-461e-9643-9c761dbae7bb"
/>
<img width="975" height="640" alt="Screenshot 2026-06-15 at 18 40 30"
src="https://github.com/user-attachments/assets/90ec0bc8-7506-4b90-bbd3-070720de799e"
/>
2b. **log in with chat** and observe intended conflict resolution logic
<img width="1165" height="224" alt="Screenshot 2026-06-15 at 17 17 30"
src="https://github.com/user-attachments/assets/80adfbf2-7dac-4f08-8b76-8eeeab6c95e7"
/>
<img width="968" height="567" alt="Screenshot 2026-06-15 at 18 38 59"
src="https://github.com/user-attachments/assets/9ea92c5e-535b-4aa4-8ad0-ee513b57bc3c"
/>
This commit is contained in:
committed by
GitHub
Unverified
parent
02dce8eb8d
commit
d959664420
@@ -992,6 +992,8 @@ impl PluginRequestProcessor {
|
||||
|
||||
let config = self.load_latest_config(config_cwd).await?;
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let auth = self.auth_manager.auth().await;
|
||||
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
|
||||
|
||||
let plugin = match read_source {
|
||||
Ok(marketplace_path) => {
|
||||
@@ -1011,7 +1013,6 @@ impl PluginRequestProcessor {
|
||||
);
|
||||
let share_context = match share_context {
|
||||
Some(context) => {
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
@@ -1118,7 +1119,6 @@ impl PluginRequestProcessor {
|
||||
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
|
||||
@@ -125,6 +125,7 @@ chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
apps = true
|
||||
"#,
|
||||
server.uri()
|
||||
),
|
||||
@@ -149,6 +150,13 @@ plugins = true
|
||||
"display_name": "Example Plugin",
|
||||
"description": "Example plugin",
|
||||
"app_ids": [],
|
||||
"app_manifest": {
|
||||
"apps": {
|
||||
"example-server": {
|
||||
"id": "example-app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"interface": {
|
||||
"short_description": "Example plugin",
|
||||
@@ -163,6 +171,12 @@ plugins = true
|
||||
"metadata": {
|
||||
"command": "example-mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "other-server",
|
||||
"metadata": {
|
||||
"command": "other-mcp"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -192,6 +206,33 @@ plugins = true
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/connectors/directory/list"))
|
||||
.and(query_param("external_logos", "true"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"apps": [
|
||||
AppInfo {
|
||||
id: "example-app".to_string(),
|
||||
name: "Example App".to_string(),
|
||||
description: Some("Example app connector".to_string()),
|
||||
logo_url: Some("https://example.com/example.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
],
|
||||
"next_token": null
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
@@ -234,7 +275,16 @@ plugins = true
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.mcp_servers,
|
||||
vec!["example-server".to_string()]
|
||||
vec!["other-server".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.apps
|
||||
.iter()
|
||||
.map(|app| app.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["example-app"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -906,6 +956,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
|
||||
.join("demo-plugin/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo_root.path().join("demo-plugin/.mcp.json"),
|
||||
r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#,
|
||||
)?;
|
||||
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
|
||||
Mock::given(method("GET"))
|
||||
@@ -1045,6 +1099,10 @@ async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() ->
|
||||
.join("demo-plugin/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo_root.path().join("demo-plugin/.mcp.json"),
|
||||
r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#,
|
||||
)?;
|
||||
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
|
||||
Mock::given(method("GET"))
|
||||
@@ -1601,6 +1659,94 @@ async fn plugin_read_returns_app_metadata_category() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_hides_apps_for_api_key_auth() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: Some(AppMetadata {
|
||||
review: None,
|
||||
categories: Some(vec!["Productivity".to_string()]),
|
||||
sub_categories: None,
|
||||
seo_description: None,
|
||||
screenshots: None,
|
||||
developer: None,
|
||||
version: None,
|
||||
version_id: None,
|
||||
version_notes: None,
|
||||
first_party_type: None,
|
||||
first_party_requires_install: None,
|
||||
show_in_composer_when_unlinked: None,
|
||||
}),
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle) = start_apps_server(connectors).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("auth.json"),
|
||||
r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#,
|
||||
)?;
|
||||
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha"])?;
|
||||
std::fs::write(
|
||||
repo_root.path().join("sample-plugin/.mcp.json"),
|
||||
r#"{"mcpServers":{"alpha":{"command":"alpha-mcp"}}}"#,
|
||||
)?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
let mut mcp = TestAppServer::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("CODEX_ACCESS_TOKEN", None),
|
||||
("CODEX_API_KEY", None),
|
||||
("OPENAI_API_KEY", None),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert!(response.plugin.apps.is_empty());
|
||||
assert_eq!(response.plugin.mcp_servers, vec!["alpha".to_string()]);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1932,6 +2078,7 @@ fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std:
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{base_url}"
|
||||
cli_auth_credentials_store = "file"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1314,7 +1314,17 @@ impl PluginsManager {
|
||||
event_name: hook.event_name,
|
||||
})
|
||||
.collect();
|
||||
let app_declarations = load_plugin_apps(source_path.as_path()).await;
|
||||
let auth_mode = self.auth_mode();
|
||||
let mut app_declarations = load_plugin_apps(source_path.as_path()).await;
|
||||
let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await;
|
||||
if auth_mode.is_some() {
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
auth_mode,
|
||||
/*plugin_active*/ true,
|
||||
);
|
||||
}
|
||||
let apps = app_connector_ids_from_declarations(&app_declarations);
|
||||
let mut seen_app_connector_ids = HashSet::new();
|
||||
let mut app_category_by_id = HashMap::new();
|
||||
@@ -1325,10 +1335,7 @@ impl PluginsManager {
|
||||
app_category_by_id.insert(app.connector_id.0.clone(), category.clone());
|
||||
}
|
||||
}
|
||||
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path(), self.auth_mode())
|
||||
.await
|
||||
.into_keys()
|
||||
.collect::<Vec<_>>();
|
||||
let mut mcp_server_names = mcp_servers.into_keys().collect::<Vec<_>>();
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
|
||||
@@ -2493,6 +2493,10 @@ plugins = true
|
||||
chatgpt_outcome.plugin.mcp_server_names,
|
||||
vec!["other-mcp".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
chatgpt_outcome.plugin.apps,
|
||||
vec![AppConnectorId("connector_sample".to_string())]
|
||||
);
|
||||
|
||||
let api_key_outcome = PluginsManager::new_with_options(
|
||||
tmp.path().to_path_buf(),
|
||||
@@ -2506,6 +2510,7 @@ plugins = true
|
||||
api_key_outcome.plugin.mcp_server_names,
|
||||
vec!["other-mcp".to_string(), "sample-mcp".to_string()]
|
||||
);
|
||||
assert!(api_key_outcome.plugin.apps.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::loader::plugin_app_declarations_from_value;
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
@@ -8,7 +10,10 @@ use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::AppDeclaration;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::app_connector_ids_from_declarations;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
@@ -16,6 +21,7 @@ use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -1064,12 +1070,29 @@ async fn build_remote_plugin_detail(
|
||||
enabled: !disabled_skill_names.contains(&skill.name),
|
||||
})
|
||||
.collect();
|
||||
let mut app_declarations = plugin
|
||||
.release
|
||||
.app_manifest
|
||||
.as_ref()
|
||||
.map(plugin_app_declarations_from_value)
|
||||
.unwrap_or_else(|| app_declarations_from_remote_app_ids(&plugin.release.app_ids));
|
||||
let mut mcp_servers = plugin
|
||||
.release
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.map(|server| server.key.clone())
|
||||
.collect::<Vec<_>>();
|
||||
.map(|server| (server.key.clone(), ()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
Some(auth.api_auth_mode()),
|
||||
/*plugin_active*/ true,
|
||||
);
|
||||
let app_ids = app_connector_ids_from_declarations(&app_declarations)
|
||||
.into_iter()
|
||||
.map(|app_id| app_id.0)
|
||||
.collect();
|
||||
let mut mcp_servers = mcp_servers.into_keys().collect::<Vec<_>>();
|
||||
mcp_servers.sort_unstable();
|
||||
mcp_servers.dedup();
|
||||
|
||||
@@ -1083,7 +1106,7 @@ async fn build_remote_plugin_detail(
|
||||
bundle_download_url: plugin.release.bundle_download_url,
|
||||
app_manifest: plugin.release.app_manifest,
|
||||
skills,
|
||||
app_ids: plugin.release.app_ids,
|
||||
app_ids,
|
||||
app_templates: plugin
|
||||
.release
|
||||
.app_templates
|
||||
@@ -1104,6 +1127,17 @@ async fn build_remote_plugin_detail(
|
||||
})
|
||||
}
|
||||
|
||||
fn app_declarations_from_remote_app_ids(app_ids: &[String]) -> Vec<AppDeclaration> {
|
||||
app_ids
|
||||
.iter()
|
||||
.map(|app_id| AppDeclaration {
|
||||
name: app_id.clone(),
|
||||
connector_id: AppConnectorId(app_id.clone()),
|
||||
category: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn install_remote_plugin(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
|
||||
Reference in New Issue
Block a user