mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
289ed549cf
commit
0243734300
@@ -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"
|
||||
],
|
||||
|
||||
+122
-3
@@ -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)]
|
||||
|
||||
@@ -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 1–3 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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user