feat: Expose plugin versions and gate plugin sharing (#22397)

- Adds localVersion to plugin summaries and remoteVersion to share
context, including generated API schemas.
- Hydrates local and remote plugin versions from manifests and remote
release metadata.
- Adds default-on plugin_sharing gate for shared-with-me listing and
plugin/share/save, with disabled-path errors
    and focused coverage.
This commit is contained in:
xl-openai
2026-05-12 17:56:30 -07:00
committed by GitHub
Unverified
parent 01b4817bac
commit d1430fd61e
22 changed files with 431 additions and 20 deletions
@@ -12289,6 +12289,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/v2/PluginSharePrincipal"
@@ -12705,6 +12713,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -8838,6 +8838,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -9254,6 +9262,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -259,6 +259,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -449,6 +457,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -313,6 +313,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -503,6 +511,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -194,6 +194,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -405,6 +413,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -4,4 +4,8 @@
import type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
import type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
export type PluginShareContext = { remotePluginId: string,
/**
* Version of the remote shared plugin release when available.
*/
remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
@@ -12,7 +12,11 @@ export type PluginSummary = { id: string,
/**
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null, name: string,
remotePluginId: string | null,
/**
* Version of the locally materialized plugin package when available.
*/
localVersion: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
@@ -540,6 +540,9 @@ pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
/// Version of the locally materialized plugin package when available.
#[serde(default)]
pub local_version: Option<String>,
pub name: String,
/// Remote sharing context associated with this plugin when available.
pub share_context: Option<PluginShareContext>,
@@ -561,6 +564,9 @@ pub struct PluginSummary {
#[ts(export_to = "v2/")]
pub struct PluginShareContext {
pub remote_plugin_id: String,
/// Version of the remote shared plugin release when available.
#[serde(default)]
pub remote_version: Option<String>,
pub discoverability: Option<PluginShareDiscoverability>,
pub share_url: Option<String>,
pub creator_account_user_id: Option<String>,
@@ -3007,6 +3007,7 @@ fn plugin_share_list_response_serializes_share_items() {
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
local_version: None,
name: "gmail".to_string(),
share_context: None,
source: PluginSource::Remote,
@@ -3027,6 +3028,7 @@ fn plugin_share_list_response_serializes_share_items() {
"plugin": {
"id": "gmail@chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"localVersion": null,
"name": "gmail",
"shareContext": null,
"source": { "type": "remote" },
@@ -3059,6 +3061,7 @@ fn plugin_summary_defaults_missing_availability_to_available() {
.unwrap();
assert_eq!(summary.availability, PluginAvailability::Available);
assert_eq!(summary.local_version, None);
assert_eq!(summary.share_context, None);
}
@@ -111,6 +111,7 @@ fn share_context_for_source(
.cloned()
.map(|remote_plugin_id| PluginShareContext {
remote_plugin_id,
remote_version: None,
discoverability: None,
share_url: None,
creator_account_user_id: None,
@@ -483,6 +484,7 @@ impl PluginRequestProcessor {
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
local_version: plugin.local_version,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
@@ -533,7 +535,9 @@ impl PluginRequestProcessor {
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe)
&& config.features.enabled(Feature::PluginSharing)
{
remote_sources.push(RemoteMarketplaceSource::SharedWithMe);
}
if !remote_sources.is_empty() {
@@ -673,17 +677,21 @@ impl PluginRequestProcessor {
)
.await
{
Ok(Some(remote_share_context))
if remote_share_context.share_principals.is_some() =>
{
Some(remote_plugin_share_context_to_info(remote_share_context))
}
Ok(Some(_)) => {
warn!(
remote_plugin_id = %context.remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context"
);
Some(context)
Ok(Some(remote_share_context)) => {
if remote_share_context.share_principals.is_some() {
Some(remote_plugin_share_context_to_info(remote_share_context))
} else {
let remote_version = remote_share_context.remote_version;
let remote_plugin_id = context.remote_plugin_id.clone();
warn!(
remote_plugin_id = %remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context with remote version"
);
Some(PluginShareContext {
remote_version,
..context
})
}
}
Ok(None) => {
warn!(
@@ -725,6 +733,7 @@ impl PluginRequestProcessor {
summary: PluginSummary {
id: outcome.plugin.id,
remote_plugin_id: None,
local_version: outcome.plugin.local_version,
name: outcome.plugin.name,
share_context,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
@@ -840,6 +849,9 @@ impl PluginRequestProcessor {
params: PluginShareSaveParams,
) -> Result<PluginShareSaveResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
@@ -1589,6 +1601,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
PluginSummary {
id: summary.id,
remote_plugin_id: Some(summary.remote_plugin_id),
local_version: None,
name: summary.name,
share_context: summary
.share_context
@@ -1609,6 +1622,7 @@ fn remote_plugin_share_context_to_info(
) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: context.remote_plugin_id,
remote_version: context.remote_version,
discoverability: Some(remote_plugin_share_discoverability_to_info(
context.discoverability,
)),
@@ -241,6 +241,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -531,6 +532,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -565,6 +567,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "missing-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -660,7 +663,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<(
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
write_plugin_share_local_path_mapping(
codex_home.path(),
@@ -692,11 +695,13 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<(
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(plugin.remote_plugin_id, None);
assert_eq!(plugin.local_version.as_deref(), Some("1.2.3"));
let share_context = plugin
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version, None);
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
@@ -1873,6 +1878,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
share_context.remote_plugin_id,
"plugins~Plugin_22222222222222222222222222222222"
);
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
@@ -1891,6 +1897,65 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"chatgpt_base_url = "{}/backend-api/"
[features]
plugins = true
plugin_sharing = false
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
}
);
wait_for_remote_plugin_request_count(
&server,
"/ps/plugins/workspace/shared",
/*expected_count*/ 0,
)
.await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -2434,6 +2499,7 @@ fn workspace_remote_plugin_page_body(
}}
],
"release": {{
"version": "1.2.3",
"display_name": "{display_name}",
"description": "Track work",
"app_ids": [],
@@ -262,6 +262,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "2.3.4",
"display_name": "Shared Linear",
"description": "Track shared work",
"app_ids": [],
@@ -330,6 +331,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
share_context.remote_plugin_id,
"plugins~Plugin_11111111111111111111111111111111"
);
assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
@@ -800,7 +802,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
@@ -833,6 +835,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.4",
"display_name": "Demo Plugin",
"description": "Shared local plugin",
"app_ids": [],
@@ -865,6 +868,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(
response.plugin.summary.local_version.as_deref(),
Some("1.2.3")
);
let share_context = response
.plugin
.summary
@@ -872,6 +879,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Unlisted)
@@ -905,6 +913,107 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
Ok(())
}
#[tokio::test]
async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_plugin_marketplace(
repo_root.path(),
"codex-curated",
"demo-plugin",
"./demo-plugin",
)?;
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
std::fs::write(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/plugins_123"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "plugins_123",
"name": "demo-plugin",
"scope": "WORKSPACE",
"discoverability": "UNLISTED",
"creator_account_user_id": "user-owner__account-123",
"creator_name": "Owner",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"share_principals": null,
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.4",
"display_name": "Demo Plugin",
"description": "Shared local plugin",
"app_ids": [],
"keywords": [],
"interface": {},
"skills": []
}
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: Some(AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)?),
remote_marketplace_name: None,
plugin_name: "demo-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(
response.plugin.summary.local_version.as_deref(),
Some("1.2.3")
);
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
assert_eq!(share_context.creator_name, None);
assert_eq!(share_context.share_principals, None);
Ok(())
}
#[tokio::test]
async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -941,6 +1050,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() ->
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(response.plugin.summary.local_version, None);
let share_context = response
.plugin
.summary
@@ -948,6 +1058,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() ->
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version, None);
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
@@ -164,6 +164,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -322,6 +323,64 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = TempDir::new()?;
let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api"
[features]
plugins = true
remote_plugin = true
plugin_sharing = false
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "plugin sharing is disabled");
assert!(
server
.received_requests()
.await
.expect("wiremock should record requests")
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -509,6 +568,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -729,6 +789,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -786,6 +847,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "0.1.0",
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
@@ -838,6 +900,7 @@ fn expected_plugin_interface() -> PluginInterface {
fn expected_share_context(plugin_id: &str) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: plugin_id.to_string(),
remote_version: Some("0.1.0".to_string()),
discoverability: Some(PluginShareDiscoverability::Private),
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
creator_account_user_id: None,
+11
View File
@@ -222,6 +222,7 @@ pub struct PluginReadOutcome {
pub struct PluginDetail {
pub id: String,
pub name: String,
pub local_version: Option<String>,
pub description: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
@@ -260,6 +261,7 @@ pub struct ConfiguredMarketplace {
pub struct ConfiguredMarketplacePlugin {
pub id: String,
pub name: String,
pub local_version: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
@@ -1199,6 +1201,7 @@ impl PluginsManager {
let installed = installed_plugins.contains(&plugin_key);
let enabled = enabled_plugins.contains(&plugin_key);
let mut interface = plugin.interface;
let mut local_version = plugin.local_version;
if installed
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
&& let Ok(plugin_id) =
@@ -1206,6 +1209,7 @@ impl PluginsManager {
&& let Some(plugin_root) = self.store.active_plugin_root(&plugin_id)
&& let Some(manifest) = load_plugin_manifest(plugin_root.as_path())
{
local_version = manifest.version.clone();
let marketplace_category = interface
.as_ref()
.and_then(|interface| interface.category.clone());
@@ -1223,6 +1227,7 @@ impl PluginsManager {
installed,
enabled,
name: plugin.name,
local_version,
source: plugin.source,
policy: plugin.policy,
keywords: plugin.keywords,
@@ -1273,6 +1278,10 @@ impl PluginsManager {
ConfiguredMarketplacePlugin {
id: plugin_key.clone(),
name: plugin.plugin_id.plugin_name,
local_version: plugin
.manifest
.as_ref()
.and_then(|manifest| manifest.version.clone()),
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
@@ -1319,6 +1328,7 @@ impl PluginsManager {
return Ok(PluginDetail {
id: plugin_key,
name: plugin.name,
local_version: None,
description: Some(description),
source: plugin.source,
policy: plugin.policy,
@@ -1411,6 +1421,7 @@ impl PluginsManager {
Ok(PluginDetail {
id: plugin.id,
name: plugin.name,
local_version: manifest.version.clone(),
description,
source: plugin.source,
policy: plugin.policy,
@@ -1541,6 +1541,7 @@ enabled = false
ConfiguredMarketplacePlugin {
id: "enabled-plugin@debug".to_string(),
name: "enabled-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
@@ -1558,6 +1559,7 @@ enabled = false
ConfiguredMarketplacePlugin {
id: "disabled-plugin@debug".to_string(),
name: "disabled-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
.unwrap(),
@@ -1678,6 +1680,7 @@ plugins = true
vec![ConfiguredMarketplacePlugin {
id: "default-plugin@debug".to_string(),
name: "default-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
},
@@ -2109,6 +2112,7 @@ enabled = true
vec![ConfiguredMarketplacePlugin {
id: "toolkit@debug".to_string(),
name: "toolkit".to_string(),
local_version: None,
source: MarketplacePluginSource::Git {
url: missing_remote_repo_url,
path: Some("plugins/toolkit".to_string()),
@@ -2225,6 +2229,7 @@ plugins = true
plugins: vec![ConfiguredMarketplacePlugin {
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
},
@@ -2519,6 +2524,7 @@ enabled = false
vec![ConfiguredMarketplacePlugin {
id: "dup-plugin@debug".to_string(),
name: "dup-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
@@ -2549,6 +2555,7 @@ enabled = false
vec![ConfiguredMarketplacePlugin {
id: "b-only-plugin@debug".to_string(),
name: "b-only-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
@@ -2633,6 +2640,7 @@ enabled = true
plugins: vec![ConfiguredMarketplacePlugin {
id: "sample-plugin@debug".to_string(),
name: "sample-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
},
+12 -4
View File
@@ -59,6 +59,7 @@ pub struct MarketplaceInterface {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePlugin {
pub name: String,
pub local_version: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
@@ -289,15 +290,22 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
Err(err) => return Err(err),
};
let local_version = plugin
.manifest
.as_ref()
.and_then(|manifest| manifest.version.clone());
let keywords = plugin
.manifest
.map(|manifest| manifest.keywords)
.unwrap_or_default();
plugins.push(MarketplacePlugin {
name: plugin.plugin_id.plugin_name,
local_version,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
keywords: plugin
.manifest
.map(|manifest| manifest.keywords)
.unwrap_or_default(),
keywords,
});
}
@@ -388,6 +388,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "string-source-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin"))
.unwrap(),
@@ -453,6 +454,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "missing-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),)
.unwrap(),
@@ -595,6 +597,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
plugins: vec![
MarketplacePlugin {
name: "shared-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(),
},
@@ -608,6 +611,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
},
MarketplacePlugin {
name: "home-only".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(),
},
@@ -630,6 +634,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
plugins: vec![
MarketplacePlugin {
name: "shared-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(),
},
@@ -643,6 +648,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
},
MarketplacePlugin {
name: "repo-only".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(),
},
@@ -721,6 +727,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
@@ -739,6 +746,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
@@ -813,6 +821,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
@@ -976,6 +985,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() {
interface: None,
plugins: vec![MarketplacePlugin {
name: "valid-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(),
},
@@ -1093,6 +1103,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
vec![
MarketplacePlugin {
name: "local-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin"))
.unwrap(),
@@ -1107,6 +1118,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
},
MarketplacePlugin {
name: "url-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Git {
url: "https://github.com/example/plugin.git".to_string(),
path: None,
@@ -1123,6 +1135,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
},
MarketplacePlugin {
name: "git-subdir-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
path: Some("plugins/example".to_string()),
+2
View File
@@ -102,6 +102,7 @@ pub struct RemotePluginSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareContext {
pub remote_plugin_id: String,
pub remote_version: Option<String>,
pub discoverability: RemotePluginShareDiscoverability,
pub share_url: Option<String>,
pub creator_account_user_id: Option<String>,
@@ -897,6 +898,7 @@ fn remote_plugin_share_context(
let discoverability = workspace_plugin_discoverability(plugin)?;
Ok(Some(RemotePluginShareContext {
remote_plugin_id: plugin.id.clone(),
remote_version: plugin.release.version.clone(),
discoverability,
share_url: plugin.share_url.clone(),
creator_account_user_id: plugin.creator_account_user_id.clone(),
@@ -97,6 +97,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "0.1.0",
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
@@ -590,6 +591,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_123".to_string(),
remote_version: Some("0.1.0".to_string()),
discoverability: RemotePluginShareDiscoverability::Private,
share_url: Some(
"https://chatgpt.example/plugins/share/share-key-1".to_string(),
@@ -628,6 +630,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_456".to_string(),
remote_version: Some("0.1.0".to_string()),
discoverability: RemotePluginShareDiscoverability::Private,
share_url: None,
creator_account_user_id: None,
+6
View File
@@ -496,6 +496,9 @@
"plugin_hooks": {
"type": "boolean"
},
"plugin_sharing": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
@@ -4173,6 +4176,9 @@
"plugin_hooks": {
"type": "boolean"
},
"plugin_sharing": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
+8
View File
@@ -188,6 +188,8 @@ pub enum Feature {
ComputerUse,
/// Temporary internal-only flag for PS-backed remote plugin catalog development.
RemotePlugin,
/// Enable remote plugin sharing flows.
PluginSharing,
/// Show the startup prompt for migrating external agent config into Codex.
ExternalMigration,
/// Allow the model to invoke the built-in image generation tool.
@@ -1007,6 +1009,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::PluginSharing,
key: "plugin_sharing",
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ExternalMigration,
key: "external_migration",
@@ -1315,6 +1315,7 @@ pub(super) fn plugins_test_summary(
PluginSummary {
id: id.to_string(),
remote_plugin_id: None,
local_version: None,
name: name.to_string(),
share_context: None,
source: PluginSource::Local {