mirror of
https://github.com/openai/codex.git
synced 2026-04-29 10:53:24 +03:00
1. Use requirement-resolved config.features as the plugin gate. 2. Guard plugin/list, plugin/read, and related flows behind that gate. 3. Skip bad marketplace.json files instead of failing the whole list. 4. Simplify plugin state and caching.
846 lines
26 KiB
Rust
846 lines
26 KiB
Rust
use std::borrow::Cow;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::DEFAULT_CLIENT_NAME;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::start_analytics_events_server;
|
|
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::AppSummary;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginInstallParams;
|
|
use codex_app_server_protocol::PluginInstallResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_core::auth::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;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"plugin/install",
|
|
Some(serde_json::json!({
|
|
"marketplacePath": "relative-marketplace.json",
|
|
"pluginName": "missing-plugin",
|
|
})),
|
|
)
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(err.error.message.contains("Invalid request"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
codex_home.path().join("missing-marketplace.json"),
|
|
)?,
|
|
plugin_name: "missing-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(err.error.message.contains("marketplace file"));
|
|
assert!(err.error.message.contains("does not exist"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
Some("NOT_AVAILABLE"),
|
|
None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(err.error.message.contains("not available for install"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()>
|
|
{
|
|
let server = MockServer::start().await;
|
|
let codex_home = TempDir::new()?;
|
|
write_plugin_remote_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
|
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",
|
|
None,
|
|
None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/backend-api/plugins/sample-plugin@debug/enable"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: true,
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
|
|
assert!(
|
|
codex_home
|
|
.path()
|
|
.join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json")
|
|
.is_file()
|
|
);
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
|
assert!(config.contains("enabled = true"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
|
let analytics_server = start_analytics_events_server().await?;
|
|
let codex_home = TempDir::new()?;
|
|
write_analytics_config(codex_home.path(), &analytics_server.uri())?;
|
|
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",
|
|
None,
|
|
None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
|
|
let payloads = timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = analytics_server.received_requests().await else {
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
continue;
|
|
};
|
|
if !requests.is_empty() {
|
|
break requests;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&payloads[0].body).expect("analytics payload");
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"events": [{
|
|
"event_type": "codex_plugin_installed",
|
|
"event_params": {
|
|
"plugin_id": "sample-plugin@debug",
|
|
"plugin_name": "sample-plugin",
|
|
"marketplace_name": "debug",
|
|
"has_skills": false,
|
|
"mcp_server_count": 0,
|
|
"connector_ids": [],
|
|
"product_client_id": DEFAULT_CLIENT_NAME,
|
|
}
|
|
}]
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_apps_needing_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",
|
|
None,
|
|
None,
|
|
)?;
|
|
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 = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
apps_needing_auth: vec![AppSummary {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
}],
|
|
}
|
|
);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_filters_disallowed_apps_needing_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(),
|
|
}];
|
|
let (server_url, server_handle) = start_apps_server(connectors, Vec::new()).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",
|
|
None,
|
|
Some("ON_USE"),
|
|
)?;
|
|
write_plugin_source(
|
|
repo_root.path(),
|
|
"sample-plugin",
|
|
&["alpha", "asdk_app_6938a94a61d881918ef32cb999ff937c"],
|
|
)?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnUse,
|
|
apps_needing_auth: vec![AppSummary {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
}],
|
|
}
|
|
);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_filters_product_restricted_plugin_skills() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
None,
|
|
None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
|
|
let plugin_root = repo_root.path().join("sample-plugin");
|
|
write_plugin_skill(
|
|
&plugin_root,
|
|
"all-products",
|
|
"Visible to every product",
|
|
&[],
|
|
)?;
|
|
write_plugin_skill(
|
|
&plugin_root,
|
|
"chatgpt-only",
|
|
"Visible to ChatGPT",
|
|
&["CHATGPT"],
|
|
)?;
|
|
write_plugin_skill(&plugin_root, "atlas-only", "Visible to Atlas", &["ATLAS"])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp =
|
|
McpProcess::new_with_args(codex_home.path(), &["--session-source", "chatgpt"]).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
force_remote_sync: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![codex_home.path().to_path_buf()],
|
|
force_reload: true,
|
|
per_cwd_extra_user_roots: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: SkillsListResponse = to_response(response)?;
|
|
|
|
let mut skills = response
|
|
.data
|
|
.into_iter()
|
|
.flat_map(|entry| entry.skills.into_iter())
|
|
.map(|skill| skill.name)
|
|
.filter(|name| name.starts_with("sample-plugin:"))
|
|
.collect::<Vec<_>>();
|
|
skills.sort_unstable();
|
|
|
|
assert_eq!(
|
|
skills,
|
|
vec![
|
|
"sample-plugin:all-products".to_string(),
|
|
"sample-plugin:chatgpt-only".to_string(),
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerState {
|
|
response: Arc<StdMutex<serde_json::Value>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct PluginInstallMcpServer {
|
|
tools: Arc<StdMutex<Vec<Tool>>>,
|
|
}
|
|
|
|
impl ServerHandler for PluginInstallMcpServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo {
|
|
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
|
..ServerInfo::default()
|
|
}
|
|
}
|
|
|
|
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(PluginInstallMcpServer {
|
|
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/apps", 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]
|
|
connectors = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!("chatgpt_base_url = \"{base_url}\"\n"),
|
|
)
|
|
}
|
|
|
|
fn write_plugin_remote_sync_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}"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
[features]
|
|
plugins = true
|
|
"#,
|
|
)
|
|
}
|
|
|
|
fn write_plugin_marketplace(
|
|
repo_root: &std::path::Path,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
source_path: &str,
|
|
install_policy: Option<&str>,
|
|
auth_policy: Option<&str>,
|
|
) -> std::io::Result<()> {
|
|
let policy = if install_policy.is_some() || auth_policy.is_some() {
|
|
let installation = install_policy
|
|
.map(|installation| format!("\n \"installation\": \"{installation}\""))
|
|
.unwrap_or_default();
|
|
let separator = if install_policy.is_some() && auth_policy.is_some() {
|
|
","
|
|
} else {
|
|
""
|
|
};
|
|
let authentication = auth_policy
|
|
.map(|authentication| {
|
|
format!("{separator}\n \"authentication\": \"{authentication}\"")
|
|
})
|
|
.unwrap_or_default();
|
|
format!(",\n \"policy\": {{{installation}{authentication}\n }}")
|
|
} else {
|
|
String::new()
|
|
};
|
|
std::fs::create_dir_all(repo_root.join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
format!(
|
|
r#"{{
|
|
"name": "{marketplace_name}",
|
|
"plugins": [
|
|
{{
|
|
"name": "{plugin_name}",
|
|
"source": {{
|
|
"source": "local",
|
|
"path": "{source_path}"
|
|
}}{policy}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_plugin_source(
|
|
repo_root: &std::path::Path,
|
|
plugin_name: &str,
|
|
app_ids: &[&str],
|
|
) -> Result<()> {
|
|
let plugin_root = repo_root.join(plugin_name);
|
|
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}"}}"#),
|
|
)?;
|
|
|
|
let apps = app_ids
|
|
.iter()
|
|
.map(|app_id| ((*app_id).to_string(), json!({ "id": app_id })))
|
|
.collect::<serde_json::Map<_, _>>();
|
|
std::fs::write(
|
|
plugin_root.join(".app.json"),
|
|
serde_json::to_vec_pretty(&json!({ "apps": apps }))?,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugin_skill(
|
|
plugin_root: &std::path::Path,
|
|
skill_name: &str,
|
|
description: &str,
|
|
products: &[&str],
|
|
) -> Result<()> {
|
|
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# {skill_name}\n"),
|
|
)?;
|
|
|
|
if !products.is_empty() {
|
|
let products = products
|
|
.iter()
|
|
.map(|product| format!(" - {product}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
std::fs::create_dir_all(skill_dir.join("agents"))?;
|
|
std::fs::write(
|
|
skill_dir.join("agents/openai.yaml"),
|
|
format!("policy:\n products:\n{products}\n"),
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|