feat: Add curated plugin marketplace + Metadata Cleanup. (#13712)

1. Add a synced curated plugin marketplace and include it in marketplace
discovery.
2. Expose optional plugin.json interface metadata in plugin/list
3. Tighten plugin and marketplace path handling using validated absolute
paths.
4. Let manifests override skill, MCP, and app config paths.
5. Restrict plugin enablement/config loading to the user config layer so
plugin enablement is at global level
This commit is contained in:
xl-openai
2026-03-06 19:39:35 -05:00
committed by GitHub
Unverified
parent 289ed549cf
commit 0243734300
23 changed files with 2107 additions and 183 deletions
@@ -969,7 +969,7 @@
"PluginListParams": {
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -11811,11 +11811,112 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -11850,7 +11951,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -11871,7 +11972,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -11895,6 +11996,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -11904,6 +12021,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -8468,11 +8468,112 @@
"title": "PluginInstallResponse",
"type": "object"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -8507,7 +8608,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -8528,7 +8629,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -8552,6 +8653,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -8561,6 +8678,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -8,7 +8,7 @@
},
"properties": {
"cwds": {
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.",
"description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -1,13 +1,118 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"defaultPrompt": {
"type": [
"string",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshots"
],
"type": "object"
},
"PluginMarketplaceEntry": {
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"plugins": {
"items": {
@@ -28,7 +133,7 @@
{
"properties": {
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
@@ -52,6 +157,22 @@
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
@@ -61,6 +182,8 @@
},
"required": [
"enabled",
"id",
"installed",
"name",
"source"
],
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
@@ -6,6 +6,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginListParams = {
/**
* Optional working directories used to discover repo marketplaces. When omitted,
* only home-scoped marketplaces are considered.
* only home-scoped marketplaces and the official curated marketplace are considered.
*/
cwds?: Array<AbsolutePathBuf> | null, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginSummary } from "./PluginSummary";
export type PluginMarketplaceEntry = { name: string, path: string, plugins: Array<PluginSummary>, };
export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, plugins: Array<PluginSummary>, };
@@ -1,5 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginSource = { "type": "local", path: string, };
export type PluginSource = { "type": "local", path: AbsolutePathBuf, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, interface: PluginInterface | null, };
@@ -152,6 +152,7 @@ export type { PatchChangeKind } from "./PatchChangeKind";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInterface } from "./PluginInterface";
export type { PluginListParams } from "./PluginListParams";
export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
@@ -2397,7 +2397,7 @@ pub struct SkillsListResponse {
#[ts(export_to = "v2/")]
pub struct PluginListParams {
/// Optional working directories used to discover repo marketplaces. When omitted,
/// only home-scoped marketplaces are considered.
/// only home-scoped marketplaces and the official curated marketplace are considered.
#[ts(optional = nullable)]
pub cwds: Option<Vec<AbsolutePathBuf>>,
}
@@ -2577,7 +2577,7 @@ pub struct SkillsListEntry {
#[ts(export_to = "v2/")]
pub struct PluginMarketplaceEntry {
pub name: String,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub plugins: Vec<PluginSummary>,
}
@@ -2585,9 +2585,32 @@ pub struct PluginMarketplaceEntry {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSummary {
pub id: String,
pub name: String,
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
pub interface: Option<PluginInterface>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2597,7 +2620,7 @@ pub struct PluginSummary {
pub enum PluginSource {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Local { path: PathBuf },
Local { path: AbsolutePathBuf },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+2 -2
View File
@@ -148,13 +148,13 @@ Example with notification opt-out:
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces, including plugin id, installed/enabled state, and optional interface metadata (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath`; on success it returns `appsNeedingAuth` for any plugin-declared apps that still are not accessible in the current ChatGPT auth context (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry and return any apps that still need auth (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 13 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
@@ -80,6 +80,7 @@ use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
@@ -411,6 +412,21 @@ pub(crate) struct CodexMessageProcessorArgs {
}
impl CodexMessageProcessor {
pub(crate) fn clear_plugin_related_caches(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
}
pub(crate) async fn maybe_start_curated_repo_sync_for_latest_config(&self) {
match self.load_latest_config(None).await {
Ok(config) => self
.thread_manager
.plugins_manager()
.maybe_start_curated_repo_sync_for_config(&config),
Err(err) => warn!("failed to load latest config for curated plugin sync: {err:?}"),
}
}
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
AccountUpdatedNotification {
@@ -5022,6 +5038,8 @@ impl CodexMessageProcessor {
.plugins
.into_iter()
.map(|plugin| PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
source: match plugin.source {
@@ -5029,6 +5047,22 @@ impl CodexMessageProcessor {
PluginSource::Local { path }
}
},
interface: plugin.interface.map(|interface| PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
long_description: interface.long_description,
developer_name: interface.developer_name,
category: interface.category,
capabilities: interface.capabilities,
website_url: interface.website_url,
privacy_policy_url: interface.privacy_policy_url,
terms_of_service_url: interface.terms_of_service_url,
default_prompt: interface.default_prompt,
brand_color: interface.brand_color,
composer_icon: interface.composer_icon,
logo: interface.logo,
screenshots: interface.screenshots,
}),
})
.collect(),
})
@@ -5190,7 +5224,7 @@ impl CodexMessageProcessor {
self.config.as_ref().clone()
}
};
let plugin_apps = load_plugin_apps(&result.installed_path);
let plugin_apps = load_plugin_apps(result.installed_path.as_path());
let apps_needing_auth = if plugin_apps.is_empty()
|| !config.features.enabled(Feature::Apps)
{
@@ -5254,8 +5288,7 @@ impl CodexMessageProcessor {
)
};
plugins_manager.clear_cache();
self.thread_manager.skills_manager().clear_cache();
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginInstallResponse { apps_needing_auth })
.await;
+17 -2
View File
@@ -194,6 +194,9 @@ impl MessageProcessor {
.enabled(codex_core::features::Feature::DefaultModeRequestUserInput),
},
));
thread_manager
.plugins_manager()
.maybe_start_curated_repo_sync_for_config(&config);
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager,
@@ -522,7 +525,13 @@ impl MessageProcessor {
params: ConfigValueWriteParams,
) {
match self.config_api.write_value(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
.maybe_start_curated_repo_sync_for_latest_config()
.await;
self.outgoing.send_response(request_id, response).await;
}
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
@@ -533,7 +542,13 @@ impl MessageProcessor {
params: ConfigBatchWriteParams,
) {
match self.config_api.batch_write(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
.maybe_start_curated_repo_sync_for_latest_config()
.await;
self.outgoing.send_response(request_id, response).await;
}
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
@@ -449,7 +449,7 @@ fn write_plugin_source(
plugin_name: &str,
app_ids: &[&str],
) -> Result<()> {
let plugin_root = repo_root.join(".agents/plugins").join(plugin_name);
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"),
@@ -125,11 +125,13 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
}
#[tokio::test]
async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?;
write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
@@ -148,6 +150,13 @@ async fn plugin_list_includes_enabled_state_from_config() -> Result<()> {
"source": "local",
"path": "./disabled-plugin"
}
},
{
"name": "uninstalled-plugin",
"source": {
"source": "local",
"path": "./uninstalled-plugin"
}
}
]
}"#,
@@ -185,16 +194,31 @@ enabled = false
.marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json")
marketplace.path
== AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)
.expect("absolute marketplace path")
})
.expect("expected repo marketplace entry");
assert_eq!(marketplace.name, "codex-curated");
assert_eq!(marketplace.plugins.len(), 2);
assert_eq!(marketplace.plugins.len(), 3);
assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated");
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
assert_eq!(marketplace.plugins[1].installed, true);
assert_eq!(marketplace.plugins[1].enabled, false);
assert_eq!(
marketplace.plugins[2].id,
"uninstalled-plugin@codex-curated"
);
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
assert_eq!(marketplace.plugins[2].installed, false);
assert_eq!(marketplace.plugins[2].enabled, false);
Ok(())
}
@@ -202,6 +226,7 @@ enabled = false
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
@@ -294,6 +319,141 @@ enabled = false
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "shared-plugin")
.expect("expected shared-plugin entry");
assert_eq!(shared_plugin.id, "shared-plugin@codex-curated");
assert_eq!(shared_plugin.installed, true);
assert_eq!(shared_plugin.enabled, true);
Ok(())
}
#[tokio::test]
async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let plugin_root = repo_root.path().join("plugins/demo-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r##"{
"name": "demo-plugin",
"interface": {
"displayName": "Plugin Display Name",
"shortDescription": "Short description for subtitle",
"longDescription": "Long description for details page",
"developerName": "OpenAI",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": "Starter prompt for trying a plugin",
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"]
}
}"##,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let plugin = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(plugin.id, "demo-plugin@codex-curated");
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
let interface = plugin
.interface
.as_ref()
.expect("expected plugin interface");
assert_eq!(
interface.display_name.as_deref(),
Some("Plugin Display Name")
);
assert_eq!(
interface.website_url.as_deref(),
Some("https://openai.com/")
);
assert_eq!(
interface.privacy_policy_url.as_deref(),
Some("https://openai.com/policies/row-privacy-policy/")
);
assert_eq!(
interface.terms_of_service_url.as_deref(),
Some("https://openai.com/policies/row-terms-of-use/")
);
assert_eq!(
interface.composer_icon,
Some(AbsolutePathBuf::try_from(
plugin_root.join("assets/icon.png")
)?)
);
assert_eq!(
interface.logo,
Some(AbsolutePathBuf::try_from(
plugin_root.join("assets/logo.png")
)?)
);
assert_eq!(
interface.screenshots,
vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?,
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?,
]
);
Ok(())
}
fn write_installed_plugin(
codex_home: &TempDir,
marketplace_name: &str,
plugin_name: &str,
) -> Result<()> {
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join(marketplace_name)
.join(plugin_name)
.join("local/.codex-plugin");
std::fs::create_dir_all(&plugin_root)?;
std::fs::write(
plugin_root.join("plugin.json"),
format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
Ok(())
}
+353
View File
@@ -0,0 +1,353 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
use std::time::Instant;
const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let remote_sha = git_ls_remote_head_sha()?;
let local_sha = read_local_sha(&repo_path, &sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
return Ok(());
}
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
let cloned_repo_path = clone_dir.path().join("repo");
let clone_output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg(&cloned_repo_path),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(&cloned_repo_path)?;
if cloned_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
));
}
if repo_path.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
};
}
} else {
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
if let Some(parent) = sha_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})?;
Ok(())
}
fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option<String> {
if repo_path.join(".git").is_dir()
&& let Ok(sha) = git_head_sha(repo_path)
{
return Some(sha);
}
fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn git_ls_remote_head_sha() -> Result<String, String> {
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg("HEAD"),
"git ls-remote curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
};
let Some((sha, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for curated plugins repo: {first_line}"
));
};
if sha.is_empty() {
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
}
Ok(sha.to_string())
}
fn git_head_sha(repo_path: &Path) -> Result<String, String> {
let output = Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.map_err(|err| {
format!(
"failed to run git rev-parse HEAD in {}: {err}",
repo_path.display()
)
})?;
ensure_git_success(&output, "git rev-parse HEAD")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(format!(
"git rev-parse HEAD returned empty output in {}",
repo_path.display()
));
}
Ok(sha)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
if start.elapsed() >= timeout {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
};
}
thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_local_sha_prefers_repo_head_when_available() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::create_dir_all(&repo_path).expect("create repo dir");
fs::write(&sha_path, "abc123\n").expect("write sha");
let init_output = Command::new("git")
.arg("init")
.arg(&repo_path)
.output()
.expect("git init should run");
ensure_git_success(&init_output, "git init").expect("git init should succeed");
let config_name_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.name")
.arg("Codex")
.output()
.expect("git config user.name should run");
ensure_git_success(&config_name_output, "git config user.name")
.expect("git config user.name should succeed");
let config_email_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.email")
.arg("codex@example.com")
.output()
.expect("git config user.email should run");
ensure_git_success(&config_email_output, "git config user.email")
.expect("git config user.email should succeed");
fs::write(repo_path.join("README.md"), "demo\n").expect("write file");
let add_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("add")
.arg(".")
.output()
.expect("git add should run");
ensure_git_success(&add_output, "git add").expect("git add should succeed");
let commit_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("commit")
.arg("-m")
.arg("init")
.output()
.expect("git commit should run");
ensure_git_success(&commit_output, "git commit").expect("git commit should succeed");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha")));
}
#[test]
fn read_local_sha_falls_back_to_sha_file() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::write(&sha_path, "abc123\n").expect("write sha");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha.as_deref(), Some("abc123"));
}
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -1,13 +1,89 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginManifest {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
#[serde(alias = "websiteURL")]
website_url: Option<String>,
#[serde(default)]
#[serde(alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default)]
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<String>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
@@ -36,3 +112,123 @@ pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path
.unwrap_or(&manifest.name)
.to_string()
}
pub(crate) fn plugin_manifest_interface(
manifest: &PluginManifest,
plugin_root: &Path,
) -> Option<PluginManifestInterfaceSummary> {
let interface = manifest.interface.as_ref()?;
let interface = PluginManifestInterfaceSummary {
display_name: interface.display_name.clone(),
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: interface.default_prompt.clone(),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
interface.composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
interface.logo.as_deref(),
),
screenshots: interface
.screenshots
.iter()
.filter_map(|screenshot| {
resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot))
})
.collect(),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.long_description.is_some()
|| interface.developer_name.is_some()
|| interface.category.is_some()
|| !interface.capabilities.is_empty()
|| interface.website_url.is_some()
|| interface.privacy_policy_url.is_some()
|| interface.terms_of_service_url.is_some()
|| interface.default_prompt.is_some()
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
}
pub(crate) fn plugin_manifest_paths(
manifest: &PluginManifest,
plugin_root: &Path,
) -> PluginManifestPaths {
PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
manifest.mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
// `plugin.json` paths are required to be relative to the plugin root and we return the
// normalized absolute path to the rest of the system.
let path = path?;
if path.is_empty() {
return None;
}
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = std::path::PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized))
.map_err(|err| {
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
err
})
.ok()
}
+240 -48
View File
@@ -1,3 +1,6 @@
use super::PluginManifestInterfaceSummary;
use super::load_plugin_manifest;
use super::plugin_manifest_interface;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
@@ -21,7 +24,7 @@ pub struct ResolvedMarketplacePlugin {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceSummary {
pub name: String,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub plugins: Vec<MarketplacePluginSummary>,
}
@@ -29,11 +32,12 @@ pub struct MarketplaceSummary {
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub interface: Option<PluginManifestInterfaceSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSourceSummary {
Local { path: PathBuf },
Local { path: AbsolutePathBuf },
}
#[derive(Debug, thiserror::Error)]
@@ -73,7 +77,7 @@ pub fn resolve_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(marketplace_path)?;
let marketplace_name = marketplace.name;
let plugin = marketplace
.plugins
@@ -92,7 +96,7 @@ pub fn resolve_marketplace_plugin(
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
})
}
@@ -109,23 +113,21 @@ fn list_marketplaces_with_home(
let mut marketplaces = Vec::new();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(&marketplace_path)?;
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source = match plugin.source {
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
path: resolve_plugin_source_path(
marketplace_path.as_path(),
MarketplacePluginSource::Local { path },
)?
.into_path_buf(),
},
let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?;
let source = MarketplacePluginSourceSummary::Local {
path: source_path.clone(),
};
let interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
plugins.push(MarketplacePluginSummary {
name: plugin.name,
source,
interface,
});
}
@@ -142,30 +144,34 @@ fn list_marketplaces_with_home(
fn discover_marketplace_paths_from_roots(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
if let Some(home) = home_dir {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
if path.is_file()
&& let Ok(path) = AbsolutePathBuf::try_from(path)
{
paths.push(path);
}
}
for root in additional_roots {
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() && !paths.contains(&path) {
paths.push(path);
}
if let Some(repo_root) = get_git_repo_root(root.as_path())
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
&& path.as_path().is_file()
&& !paths.contains(&path)
{
paths.push(path);
}
}
paths
}
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path).map_err(|err| {
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
MarketplaceError::MarketplaceNotFound {
path: path.to_path_buf(),
@@ -181,7 +187,7 @@ fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
}
fn resolve_plugin_source_path(
marketplace_path: &Path,
marketplace_path: &AbsolutePathBuf,
source: MarketplacePluginSource,
) -> Result<AbsolutePathBuf, MarketplaceError> {
match source {
@@ -206,25 +212,61 @@ fn resolve_plugin_source_path(
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must stay within the marketplace directory"
message: "local plugin source path must stay within the marketplace root"
.to_string(),
});
}
let source_path = marketplace_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(relative_source_path);
AbsolutePathBuf::try_from(source_path).map_err(|err| {
MarketplaceError::InvalidMarketplaceFile {
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
marketplace_root_dir(marketplace_path)?
.join(relative_source_path)
.map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: format!("plugin source path must resolve to an absolute path: {err}"),
}
})
})
}
}
}
fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
let Some(plugins_dir) = marketplace_path.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(dot_agents_dir) = plugins_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(marketplace_root) = dot_agents_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins")
|| dot_agents_dir
.as_path()
.file_name()
.and_then(|s| s.to_str())
!= Some(".agents")
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
}
Ok(marketplace_root)
}
#[derive(Debug, Deserialize)]
struct MarketplaceFile {
name: String,
@@ -284,8 +326,7 @@ mod tests {
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1"))
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
}
);
}
@@ -381,37 +422,51 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
home_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-shared"),
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "home-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-only"),
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
.unwrap(),
},
interface: None,
},
],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-shared"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "repo-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-only"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
.unwrap(),
},
interface: None,
},
],
},
@@ -475,22 +530,24 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_marketplace,
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-plugin"),
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
interface: None,
}],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_marketplace.clone(),
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
interface: None,
}],
},
]
@@ -504,7 +561,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
);
}
@@ -547,17 +604,152 @@ mod tests {
marketplaces,
vec![MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
interface: None,
}],
}]
);
}
#[test]
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive", "Write"],
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": ["./assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: Some(
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
),
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
screenshots: vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
],
})
);
}
#[test]
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive"],
"composerIcon": "assets/icon.png",
"logo": "/tmp/logo.png",
"screenshots": ["assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
})
);
}
#[test]
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
let tmp = tempdir().unwrap();
@@ -634,7 +826,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
);
}
}
+7
View File
@@ -1,3 +1,4 @@
mod curated_repo;
mod injection;
mod manager;
mod manifest;
@@ -5,6 +6,8 @@ mod marketplace;
mod render;
mod store;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
@@ -17,8 +20,12 @@ pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub use manager::load_plugin_apps;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manifest::PluginManifestInterfaceSummary;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_interface;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use manifest::plugin_manifest_paths;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
+29 -16
View File
@@ -61,7 +61,7 @@ impl PluginId {
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: PathBuf,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
@@ -92,19 +92,25 @@ impl PluginStore {
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION)
.as_path()
.is_dir()
}
pub fn install(
&self,
source_path: PathBuf,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.is_dir() {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(&source_path)?;
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
@@ -112,18 +118,16 @@ impl PluginStore {
)));
}
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
let installed_path = self
.plugin_root(&plugin_id, &plugin_version)
.into_path_buf();
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
if let Some(parent) = installed_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
fs::create_dir_all(parent.as_path()).map_err(|err| {
PluginStoreError::io("failed to create plugin cache directory", err)
})?;
}
remove_existing_target(&installed_path)?;
copy_dir_recursive(&source_path, &installed_path)?;
remove_existing_target(installed_path.as_path())?;
copy_dir_recursive(source_path.as_path(), installed_path.as_path())?;
Ok(PluginInstallResult {
plugin_id,
@@ -257,7 +261,10 @@ mod tests {
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
@@ -266,7 +273,7 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: installed_path.clone(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
@@ -280,7 +287,10 @@ mod tests {
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("source-dir"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
@@ -288,7 +298,10 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
installed_path: AbsolutePathBuf::try_from(
tmp.path().join("plugins/cache/market/manifest-name/local"),
)
.unwrap(),
}
);
}
@@ -327,7 +340,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
@@ -355,7 +368,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();