[codex] Remove redundant plugin app auth state (#27465)

## Summary

- remove the redundant `needsAuth` field from `AppSummary` and generated
app-server schemas
- stop `plugin/read` from querying Apps MCP solely to hydrate unused
connector auth state
- preserve `plugin/install.appsNeedingAuth` membership and
`app/list.isAccessible` as the authentication signals

## Why

Codex App and TUI do not consume `plugin/read.plugin.apps[].needsAuth`.
Hydrating it could establish an Apps MCP connection and discover tools
on a cold `plugin/read` request, adding avoidable latency. The plugin
APIs are still marked under development, so removing this wire field is
preferable to retaining a misleading default.

## Verification

- `just write-app-server-schema`
- `just fmt`
- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
plugin_install_uses_remote_apps_needing_auth_response`
- `just test -p codex-app-server
plugin_install_returns_apps_needing_auth`
- `just test -p codex-app-server
plugin_read_returns_plugin_details_with_bundle_contents`
- `just test -p codex-tui
plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries`
- `$xin-build` simplify and debug reviews
This commit is contained in:
xl-openai
2026-06-10 17:33:56 -07:00
committed by GitHub
Unverified
parent b389b950e1
commit 1a9efd473b
12 changed files with 13 additions and 356 deletions
@@ -6433,15 +6433,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -711,15 +711,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -21,15 +21,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -25,15 +25,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -5,4 +5,4 @@
/**
* EXPERIMENTAL - app metadata summary for plugin responses.
*/
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, needsAuth: boolean, };
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, };
@@ -111,7 +111,6 @@ pub struct AppSummary {
pub name: String,
pub description: Option<String>,
pub install_url: Option<String>,
pub needs_auth: bool,
}
impl From<AppInfo> for AppSummary {
@@ -121,7 +120,6 @@ impl From<AppInfo> for AppSummary {
name: value.name,
description: value.description,
install_url: value.install_url,
needs_auth: false,
}
}
}
+1 -1
View File
@@ -206,7 +206,7 @@ Example with notification opt-out:
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
@@ -1035,14 +1035,7 @@ impl PluginRequestProcessor {
}
None => None,
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
let app_summaries = load_plugin_app_summaries(&config, &outcome.plugin.apps).await;
let visible_skills = outcome
.plugin
.skills
@@ -1118,14 +1111,7 @@ impl PluginRequestProcessor {
.cloned()
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = load_plugin_app_summaries(
&config,
&plugin_apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
let app_summaries = load_plugin_app_summaries(&config, &plugin_apps).await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
@@ -1582,7 +1568,6 @@ impl PluginRequestProcessor {
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
})
.collect()
}
@@ -1887,8 +1872,6 @@ impl PluginRequestProcessor {
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
@@ -1906,49 +1889,9 @@ async fn load_plugin_app_summaries(
};
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ false,
environment_manager,
mcp_manager,
)
.await
{
Ok(status) if status.codex_apps_ready => status.connectors,
Ok(_) => {
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
Err(err) => {
warn!("failed to load app auth state for plugin/read: {err:#}");
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
};
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
plugin_connectors
.into_iter()
.map(|connector| {
let needs_auth = !accessible_ids.contains(connector.id.as_str());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth,
}
})
.map(AppSummary::from)
.collect()
}
@@ -1983,7 +1926,6 @@ fn plugin_apps_needing_auth(
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
})
.collect()
}
@@ -327,7 +327,6 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
name: "alpha".to_string(),
description: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
}],
}
);
@@ -1001,7 +1000,6 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
}],
}
);
@@ -1089,7 +1087,6 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
}],
}
);
@@ -1,6 +1,3 @@
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use anyhow::Result;
@@ -8,15 +5,6 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::Uri;
use axum::http::header::AUTHORIZATION;
use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::HookEventName;
@@ -37,21 +25,8 @@ use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::JsonObject;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use rmcp::transport::StreamableHttpServerConfig;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
@@ -1483,104 +1458,11 @@ enabled = false
response.plugin.apps[0].install_url.as_deref(),
Some("https://chatgpt.com/apps/gmail/gmail")
);
assert_eq!(response.plugin.apps[0].needs_auth, true);
assert_eq!(response.plugin.mcp_servers.len(), 1);
assert_eq!(response.plugin.mcp_servers[0], "demo");
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_app_needs_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: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
},
AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
},
];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
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 repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = TestAppServer::new(codex_home.path()).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_eq!(
response
.plugin
.apps
.iter()
.map(|app| (app.id.as_str(), app.needs_auth))
.collect::<Vec<_>>(),
vec![("alpha", true), ("beta", false)]
);
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()?;
@@ -1846,151 +1728,6 @@ plugins = true
Ok(())
}
#[derive(Clone)]
struct AppsServerState {
response: Arc<StdMutex<serde_json::Value>>,
}
#[derive(Clone)]
struct PluginReadMcpServer {
tools: Arc<StdMutex<Vec<Tool>>>,
}
impl ServerHandler for PluginReadMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
{
let tools = self.tools.clone();
async move {
let tools = tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
}
}
async fn start_apps_server(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
response: Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
)),
});
let tools = Arc::new(StdMutex::new(tools));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let mcp_service = StreamableHttpService::new(
{
let tools = tools.clone();
move || {
Ok(PluginReadMcpServer {
tools: tools.clone(),
})
}
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let router = Router::new()
.route("/connectors/directory/list", get(list_directory_connectors))
.route(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/ps/mcp", mcp_service);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Ok((format!("http://{addr}"), handle))
}
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
uri: Uri,
) -> Result<impl axum::response::IntoResponse, StatusCode> {
let bearer_ok = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "Bearer chatgpt-token");
let account_ok = headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "account-123");
let external_logos_ok = uri
.query()
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
if !bearer_ok || !account_ok {
Err(StatusCode::UNAUTHORIZED)
} else if !external_logos_ok {
Err(StatusCode::BAD_REQUEST)
} else {
let response = state
.response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(Json(response))
}
}
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
let schema: JsonObject = serde_json::from_value(json!({
"type": "object",
"additionalProperties": false
}))?;
let mut tool = Tool::new(
Cow::Owned(format!("connector_{connector_id}")),
Cow::Borrowed("Connector test tool"),
Arc::new(schema),
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
let mut meta = Meta::new();
meta.0
.insert("connector_id".to_string(), json!(connector_id));
meta.0
.insert("connector_name".to_string(), json!(connector_name));
tool.meta = Some(meta);
Ok(tool)
}
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
mcp_oauth_credentials_store = "file"
[features]
plugins = true
connectors = true
"#
),
)
}
fn write_remote_plugin_catalog_config(
codex_home: &std::path::Path,
base_url: &str,
+2 -3
View File
@@ -1414,7 +1414,7 @@ pub(super) fn plugins_test_detail(
description: Option<&str>,
skills: &[&str],
hooks: &[(codex_app_server_protocol::HookEventName, usize)],
apps: &[(&str, bool)],
apps: &[&str],
mcp_servers: &[&str],
) -> PluginDetail {
PluginDetail {
@@ -1449,12 +1449,11 @@ pub(super) fn plugins_test_detail(
.collect(),
apps: apps
.iter()
.map(|(name, needs_auth)| AppSummary {
.map(|name| AppSummary {
id: format!("{name}-id"),
name: (*name).to_string(),
description: Some(format!("{name} app")),
install_url: Some(format!("https://example.test/{name}")),
needs_auth: *needs_auth,
})
.collect(),
app_templates: Vec::new(),
@@ -654,7 +654,7 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&[("Figma", true), ("Slack", false)],
&["Figma", "Slack"],
&["figma-mcp", "docs-mcp"],
),
}),
@@ -698,7 +698,7 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&[("Figma", true), ("Slack", false)],
&["Figma", "Slack"],
&["figma-mcp", "docs-mcp"],
),
}),