chore: wire through plugin policies + category from marketplace.json (#14305)

wire plugin marketplace metadata through app-server endpoints:
- `plugin/list` has `installPolicy` and `authPolicy`
- `plugin/install` has plugin-level `authPolicy`

`plugin/install` also now enforces `NOT_AVAILABLE` `installPolicy` when
installing.


added tests.
This commit is contained in:
sayan-oai 2026-03-11 10:37:40 -07:00 committed by Michael Bolin
parent fa1242c83b
commit 7b2cee53db
18 changed files with 429 additions and 22 deletions

View file

@ -12749,6 +12749,13 @@
],
"type": "string"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@ -12766,6 +12773,14 @@
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@ -12774,6 +12789,16 @@
"$ref": "#/definitions/v2/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
@ -12974,12 +12999,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},

View file

@ -9097,6 +9097,13 @@
],
"type": "string"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@ -9114,6 +9121,14 @@
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@ -9122,6 +9137,16 @@
"$ref": "#/definitions/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
@ -9322,12 +9347,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},

View file

@ -28,6 +28,13 @@
"name"
],
"type": "object"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
}
},
"properties": {
@ -36,6 +43,16 @@
"$ref": "#/definitions/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [

View file

@ -5,6 +5,21 @@
"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"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@ -154,12 +169,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},

View file

@ -0,0 +1,5 @@
// 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.
export type PluginAuthPolicy = "ON_INSTALL" | "ON_USE";

View file

@ -0,0 +1,5 @@
// 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.
export type PluginInstallPolicy = "NOT_AVAILABLE" | "AVAILABLE" | "INSTALLED_BY_DEFAULT";

View file

@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppSummary } from "./AppSummary";
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type PluginInstallResponse = { appsNeedingAuth: Array<AppSummary>, };
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy | null, appsNeedingAuth: Array<AppSummary>, };

View file

@ -1,7 +1,9 @@
// 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 { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, interface: PluginInterface | null, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy | null, authPolicy: PluginAuthPolicy | null, interface: PluginInterface | null, };

View file

@ -175,7 +175,9 @@ export type { PermissionGrantScope } from "./PermissionGrantScope";
export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams";
export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInterface } from "./PluginInterface";
export type { PluginListParams } from "./PluginListParams";

View file

@ -3053,6 +3053,31 @@ pub struct PluginMarketplaceEntry {
pub plugins: Vec<PluginSummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
#[ts(rename = "NOT_AVAILABLE")]
NotAvailable,
#[serde(rename = "AVAILABLE")]
#[ts(rename = "AVAILABLE")]
Available,
#[serde(rename = "INSTALLED_BY_DEFAULT")]
#[ts(rename = "INSTALLED_BY_DEFAULT")]
InstalledByDefault,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginAuthPolicy {
#[serde(rename = "ON_INSTALL")]
#[ts(rename = "ON_INSTALL")]
OnInstall,
#[serde(rename = "ON_USE")]
#[ts(rename = "ON_USE")]
OnUse,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -3062,6 +3087,8 @@ pub struct PluginSummary {
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
pub install_policy: Option<PluginInstallPolicy>,
pub auth_policy: Option<PluginAuthPolicy>,
pub interface: Option<PluginInterface>,
}
@ -3122,6 +3149,7 @@ pub struct PluginInstallParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallResponse {
pub auth_policy: Option<PluginAuthPolicy>,
pub apps_needing_auth: Vec<AppSummary>,
}

View file

@ -157,13 +157,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 plugin marketplaces and plugin state. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**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 and return any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**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).

View file

@ -4685,6 +4685,7 @@ impl CodexMessageProcessor {
}
MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::InvalidPlugin(_) => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
@ -5399,6 +5400,8 @@ impl CodexMessageProcessor {
PluginSource::Local { path }
}
},
install_policy: plugin.install_policy.map(Into::into),
auth_policy: plugin.auth_policy.map(Into::into),
interface: plugin.interface.map(|interface| PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
@ -5648,7 +5651,13 @@ impl CodexMessageProcessor {
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginInstallResponse { apps_needing_auth })
.send_response(
request_id,
PluginInstallResponse {
auth_policy: result.auth_policy.map(Into::into),
apps_needing_auth,
},
)
.await;
}
Err(err) => {

View file

@ -19,6 +19,7 @@ use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::RequestId;
@ -98,6 +99,43 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
Some("NOT_AVAILABLE"),
None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("not available for install"));
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
let connectors = vec![
@ -152,6 +190,8 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
"debug",
"sample-plugin",
"./sample-plugin",
None,
Some("ON_INSTALL"),
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
let marketplace_path =
@ -177,6 +217,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnInstall),
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@ -227,6 +268,8 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
"debug",
"sample-plugin",
"./sample-plugin",
None,
Some("ON_USE"),
)?;
write_plugin_source(
repo_root.path(),
@ -256,6 +299,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnUse),
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@ -422,7 +466,15 @@ fn write_plugin_marketplace(
marketplace_name: &str,
plugin_name: &str,
source_path: &str,
install_policy: Option<&str>,
auth_policy: Option<&str>,
) -> std::io::Result<()> {
let install_policy = install_policy
.map(|install_policy| format!(",\n \"installPolicy\": \"{install_policy}\""))
.unwrap_or_default();
let auth_policy = auth_policy
.map(|auth_policy| format!(",\n \"authPolicy\": \"{auth_policy}\""))
.unwrap_or_default();
std::fs::create_dir_all(repo_root.join(".git"))?;
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
std::fs::write(
@ -436,7 +488,7 @@ fn write_plugin_marketplace(
"source": {{
"source": "local",
"path": "{source_path}"
}}
}}{install_policy}{auth_policy}
}}
]
}}"#

View file

@ -6,6 +6,8 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
@ -358,7 +360,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"category": "Design"
}
]
}"#,
@ -413,6 +418,8 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
assert_eq!(plugin.id, "demo-plugin@codex-curated");
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
assert_eq!(plugin.install_policy, Some(PluginInstallPolicy::Available));
assert_eq!(plugin.auth_policy, Some(PluginAuthPolicy::OnInstall));
let interface = plugin
.interface
.as_ref()
@ -421,6 +428,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
interface.display_name.as_deref(),
Some("Plugin Display Name")
);
assert_eq!(interface.category.as_deref(), Some("Design"));
assert_eq!(
interface.website_url.as_deref(),
Some("https://openai.com/")

View file

@ -3,6 +3,8 @@ use super::curated_plugins_repo_path;
use super::load_plugin_manifest;
use super::manifest::PluginManifestInterfaceSummary;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplacePluginAuthPolicy;
use super::marketplace::MarketplacePluginInstallPolicy;
use super::marketplace::MarketplacePluginSourceSummary;
use super::marketplace::list_marketplaces;
use super::marketplace::load_marketplace_summary;
@ -12,7 +14,7 @@ use super::plugin_manifest_paths;
use super::store::DEFAULT_PLUGIN_VERSION;
use super::store::PluginId;
use super::store::PluginIdError;
use super::store::PluginInstallResult;
use super::store::PluginInstallResult as StorePluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
use super::sync_openai_plugins_repo;
@ -68,6 +70,14 @@ pub struct PluginInstallRequest {
pub marketplace_path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallOutcome {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: AbsolutePathBuf,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceSummary {
pub name: String,
@ -80,6 +90,8 @@ pub struct ConfiguredMarketplacePluginSummary {
pub id: String,
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: Option<MarketplacePluginInstallPolicy>,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
pub interface: Option<PluginManifestInterfaceSummary>,
pub installed: bool,
pub enabled: bool,
@ -380,10 +392,11 @@ impl PluginsManager {
pub async fn install_plugin(
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
let auth_policy = resolved.auth_policy;
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || {
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
store.install(resolved.source_path, resolved.plugin_id)
})
.await
@ -403,7 +416,12 @@ impl PluginsManager {
.map(|_| ())
.map_err(PluginInstallError::from)?;
Ok(result)
Ok(PluginInstallOutcome {
plugin_id: result.plugin_id,
plugin_version: result.plugin_version,
installed_path: result.installed_path,
auth_policy,
})
}
pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> {
@ -634,6 +652,8 @@ impl PluginsManager {
.unwrap_or(false),
name: plugin.name,
source: plugin.source,
install_policy: plugin.install_policy,
auth_policy: plugin.auth_policy,
interface: plugin.interface,
})
})
@ -760,6 +780,7 @@ impl PluginInstallError {
MarketplaceError::MarketplaceNotFound { .. }
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
@ -1925,7 +1946,8 @@ mod tests {
"source": {
"source": "local",
"path": "./sample-plugin"
}
},
"authPolicy": "ON_USE"
}
]
}"#,
@ -1946,10 +1968,11 @@ mod tests {
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
PluginInstallOutcome {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: Some(MarketplacePluginAuthPolicy::OnUse),
}
);
@ -2079,6 +2102,8 @@ enabled = false
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: true,
enabled: true,
@ -2092,6 +2117,8 @@ enabled = false
)
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: true,
enabled: false,
@ -2157,6 +2184,8 @@ enabled = false
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: false,
@ -2255,6 +2284,8 @@ enabled = false
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: true,
@ -2279,6 +2310,8 @@ enabled = false
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: false,
@ -2356,6 +2389,8 @@ enabled = true
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: true,

View file

@ -32,7 +32,7 @@ pub struct PluginManifestPaths {
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,

View file

@ -4,6 +4,8 @@ use super::plugin_manifest_interface;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use serde::Deserialize;
@ -19,6 +21,7 @@ const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
pub struct ResolvedMarketplacePlugin {
pub plugin_id: PluginId,
pub source_path: AbsolutePathBuf,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -32,6 +35,8 @@ pub struct MarketplaceSummary {
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: Option<MarketplacePluginInstallPolicy>,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
pub interface: Option<PluginManifestInterfaceSummary>,
}
@ -40,6 +45,43 @@ pub enum MarketplacePluginSourceSummary {
Local { path: AbsolutePathBuf },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
NotAvailable,
#[serde(rename = "AVAILABLE")]
Available,
#[serde(rename = "INSTALLED_BY_DEFAULT")]
InstalledByDefault,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginAuthPolicy {
#[serde(rename = "ON_INSTALL")]
OnInstall,
#[serde(rename = "ON_USE")]
OnUse,
}
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
fn from(value: MarketplacePluginInstallPolicy) -> Self {
match value {
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
MarketplacePluginInstallPolicy::Available => Self::Available,
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
}
}
}
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
fn from(value: MarketplacePluginAuthPolicy) -> Self {
match value {
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceError {
#[error("{context}: {source}")]
@ -61,6 +103,14 @@ pub enum MarketplaceError {
marketplace_name: String,
},
#[error(
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
)]
PluginNotAvailable {
plugin_name: String,
marketplace_name: String,
},
#[error("{0}")]
InvalidPlugin(String),
}
@ -91,12 +141,27 @@ pub fn resolve_marketplace_plugin(
});
};
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err {
let MarketplacePlugin {
name,
source,
install_policy,
auth_policy,
..
} = plugin;
if install_policy == Some(MarketplacePluginInstallPolicy::NotAvailable) {
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: name,
marketplace_name,
});
}
let plugin_id = PluginId::new(name, marketplace_name).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
source_path: resolve_plugin_source_path(marketplace_path, source)?,
auth_policy,
})
}
@ -113,16 +178,31 @@ pub(crate) fn load_marketplace_summary(
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source_path = resolve_plugin_source_path(path, plugin.source)?;
let MarketplacePlugin {
name,
source,
install_policy,
auth_policy,
category,
} = plugin;
let source_path = resolve_plugin_source_path(path, source)?;
let source = MarketplacePluginSourceSummary::Local {
path: source_path.clone(),
};
let interface = load_plugin_manifest(source_path.as_path())
let mut interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterfaceSummary::default)
.category = Some(category);
}
plugins.push(MarketplacePluginSummary {
name: plugin.name,
name,
source,
install_policy,
auth_policy,
interface,
});
}
@ -280,9 +360,16 @@ struct MarketplaceFile {
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MarketplacePlugin {
name: String,
source: MarketplacePluginSource,
#[serde(default)]
install_policy: Option<MarketplacePluginInstallPolicy>,
#[serde(default)]
auth_policy: Option<MarketplacePluginAuthPolicy>,
#[serde(default)]
category: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -333,6 +420,7 @@ mod tests {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
auth_policy: None,
}
);
}
@ -439,6 +527,8 @@ mod tests {
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
MarketplacePluginSummary {
@ -447,6 +537,8 @@ mod tests {
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
],
@ -464,6 +556,8 @@ mod tests {
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
MarketplacePluginSummary {
@ -472,6 +566,8 @@ mod tests {
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
],
@ -542,6 +638,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
},
@ -553,6 +651,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
},
@ -617,6 +717,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
}]
@ -641,7 +743,10 @@ mod tests {
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"category": "Design"
}
]
}"#,
@ -653,6 +758,7 @@ mod tests {
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
@ -666,6 +772,14 @@ mod tests {
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].install_policy,
Some(MarketplacePluginInstallPolicy::Available)
);
assert_eq!(
marketplaces[0].plugins[0].auth_policy,
Some(MarketplacePluginAuthPolicy::OnInstall)
);
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
@ -673,7 +787,7 @@ mod tests {
short_description: None,
long_description: None,
developer_name: None,
category: None,
category: Some("Design".to_string()),
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
@ -754,6 +868,8 @@ mod tests {
screenshots: Vec::new(),
})
);
assert_eq!(marketplaces[0].plugins[0].install_policy, None);
assert_eq!(marketplaces[0].plugins[0].auth_policy, None);
}
#[test]

View file

@ -15,6 +15,7 @@ pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginRemoteSyncError;
@ -30,8 +31,9 @@ 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::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;
pub use store::PluginInstallResult;