From 7b2cee53dba4e7d20a365c4942dd67cbeffcd8ab Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 11 Mar 2026 10:37:40 -0700 Subject: [PATCH] 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. --- .../codex_app_server_protocol.schemas.json | 45 ++++++ .../codex_app_server_protocol.v2.schemas.json | 45 ++++++ .../schema/json/v2/PluginInstallResponse.json | 17 +++ .../schema/json/v2/PluginListResponse.json | 35 +++++ .../schema/typescript/v2/PluginAuthPolicy.ts | 5 + .../typescript/v2/PluginInstallPolicy.ts | 5 + .../typescript/v2/PluginInstallResponse.ts | 3 +- .../schema/typescript/v2/PluginSummary.ts | 4 +- .../schema/typescript/v2/index.ts | 2 + .../app-server-protocol/src/protocol/v2.rs | 28 ++++ codex-rs/app-server/README.md | 4 +- .../app-server/src/codex_message_processor.rs | 11 +- .../tests/suite/v2/plugin_install.rs | 54 +++++++- .../app-server/tests/suite/v2/plugin_list.rs | 10 +- codex-rs/core/src/plugins/manager.rs | 47 ++++++- codex-rs/core/src/plugins/manifest.rs | 2 +- codex-rs/core/src/plugins/marketplace.rs | 130 +++++++++++++++++- codex-rs/core/src/plugins/mod.rs | 4 +- 18 files changed, 429 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0a8dd747f..421423bbc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 79add1f96..f7a0dbb47 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json index a294dbcba..daa832644 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index e6d638c3c..39a0b659c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts new file mode 100644 index 000000000..5b90e9c31 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts new file mode 100644 index 000000000..d624f38ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts index 08c61f37d..d4ea0afbb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts @@ -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, }; +export type PluginInstallResponse = { authPolicy: PluginAuthPolicy | null, appsNeedingAuth: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index baefe10dd..358914cae 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index aa39c5c3e..b57daaac3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5df54e73a..aaf0484a5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3053,6 +3053,31 @@ pub struct PluginMarketplaceEntry { pub plugins: Vec, } +#[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, + pub auth_policy: Option, pub interface: Option, } @@ -3122,6 +3149,7 @@ pub struct PluginInstallParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginInstallResponse { + pub auth_policy: Option, pub apps_needing_auth: Vec, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a680a94a2..1fec3a35d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 1–3 short questions for a tool call and return their answers (experimental). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 67d1f18fc..a4909c774 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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) => { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 7652ca1ae..2a76f6add 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -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} }} ] }}"# diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 53b258d19..dfbd88e08 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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/") diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index cd2b82c3d..770512b2f 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -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, +} + #[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, + pub auth_policy: Option, pub interface: Option, pub installed: bool, pub enabled: bool, @@ -380,10 +392,11 @@ impl PluginsManager { pub async fn install_plugin( &self, request: PluginInstallRequest, - ) -> Result { + ) -> Result { 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, diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index ae43fd015..b7325b400 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -32,7 +32,7 @@ pub struct PluginManifestPaths { pub apps: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginManifestInterfaceSummary { pub display_name: Option, pub short_description: Option, diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index e33ef8911..fb0abd205 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -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, } #[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, + pub auth_policy: Option, pub interface: Option, } @@ -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 for PluginInstallPolicy { + fn from(value: MarketplacePluginInstallPolicy) -> Self { + match value { + MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable, + MarketplacePluginInstallPolicy::Available => Self::Available, + MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault, + } + } +} + +impl From 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, + #[serde(default)] + auth_policy: Option, + #[serde(default)] + category: Option, } #[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] diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 265ef8b75..2b92037d4 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -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;