feat: Add plugin share access controls (#21124)

Extends `plugin/share/save` to accept optional discoverability and
shareTargets while uploading plugin contents, and adds
`plugin/share/updateTargets` for share-only target updates without
re-uploading.
This commit is contained in:
xl-openai
2026-05-05 20:14:18 -07:00
committed by GitHub
Unverified
parent b3d4f1a9f0
commit 5119680f85
25 changed files with 1206 additions and 2 deletions
@@ -2197,11 +2197,37 @@
],
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListParams": {
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -2210,6 +2236,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -2217,6 +2252,39 @@
],
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"type": "object"
},
"PluginSkillReadParams": {
"properties": {
"remoteMarketplaceName": {
@@ -5177,6 +5245,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -810,6 +810,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12479,6 +12503,14 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
@@ -12525,9 +12557,46 @@
"title": "PluginShareListResponse",
"type": "object"
},
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/v2/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -12536,6 +12605,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/v2/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -12561,6 +12639,57 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/v2/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/v2/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
},
"PluginShareUpdateTargetsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/v2/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1569,6 +1569,30 @@
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/updateTargets"
],
"title": "Plugin/share/updateTargetsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareUpdateTargetsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/updateTargetsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9090,6 +9114,14 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
@@ -9136,9 +9168,46 @@
"title": "PluginShareListResponse",
"type": "object"
},
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -9147,6 +9216,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -9172,6 +9250,57 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
},
"PluginShareUpdateTargetsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
},
"PluginShareUpdateTargetsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4,9 +4,50 @@
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"PluginShareDiscoverability": {
"enum": [
"LISTED",
"UNLISTED",
"PRIVATE"
],
"type": "string"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
}
},
"properties": {
"discoverability": {
"anyOf": [
{
"$ref": "#/definitions/PluginShareDiscoverability"
},
{
"type": "null"
}
]
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -15,6 +56,15 @@
"string",
"null"
]
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
},
"PluginShareTarget": {
"properties": {
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"principalId",
"principalType"
],
"type": "object"
}
},
"properties": {
"remotePluginId": {
"type": "string"
},
"shareTargets": {
"items": {
"$ref": "#/definitions/PluginShareTarget"
},
"type": "array"
}
},
"required": [
"remotePluginId",
"shareTargets"
],
"title": "PluginShareUpdateTargetsParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PluginSharePrincipal": {
"properties": {
"name": {
"type": "string"
},
"principalId": {
"type": "string"
},
"principalType": {
"$ref": "#/definitions/PluginSharePrincipalType"
}
},
"required": [
"name",
"principalId",
"principalType"
],
"type": "object"
},
"PluginSharePrincipalType": {
"enum": [
"user",
"group",
"workspace"
],
"type": "string"
}
},
"properties": {
"principals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
},
"type": "array"
}
},
"required": [
"principals"
],
"title": "PluginShareUpdateTargetsResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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 PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, };
@@ -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 PluginSharePrincipalType = "user" | "group" | "workspace";
@@ -2,5 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
import type { PluginShareTarget } from "./PluginShareTarget";
export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, };
export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array<PluginShareTarget> | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginShareTarget } from "./PluginShareTarget";
export type PluginShareUpdateTargetsParams = { remotePluginId: string, shareTargets: Array<PluginShareTarget>, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type PluginShareUpdateTargetsResponse = { principals: Array<PluginSharePrincipal>, };
@@ -285,11 +285,17 @@ export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
export type { PluginShareListItem } from "./PluginShareListItem";
export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type { PluginSharePrincipalType } from "./PluginSharePrincipalType";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
export type { PluginShareTarget } from "./PluginShareTarget";
export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams";
export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse";
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
export type { PluginSource } from "./PluginSource";
@@ -628,6 +628,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginShareSaveResponse,
},
PluginShareUpdateTargets => "plugin/share/updateTargets" {
params: v2::PluginShareUpdateTargetsParams,
serialization: global("config"),
response: v2::PluginShareUpdateTargetsResponse,
},
PluginShareList => "plugin/share/list" {
params: v2::PluginShareListParams,
serialization: global("config"),
@@ -194,6 +194,10 @@ pub struct PluginShareSaveParams {
pub plugin_path: AbsolutePathBuf,
#[ts(optional = nullable)]
pub remote_plugin_id: Option<String>,
#[ts(optional = nullable)]
pub discoverability: Option<PluginShareDiscoverability>,
#[ts(optional = nullable)]
pub share_targets: Option<Vec<PluginShareTarget>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -204,6 +208,21 @@ pub struct PluginShareSaveResponse {
pub share_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareUpdateTargetsParams {
pub remote_plugin_id: String,
pub share_targets: Vec<PluginShareTarget>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareUpdateTargetsResponse {
pub principals: Vec<PluginSharePrincipal>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -237,6 +256,51 @@ pub struct PluginShareListItem {
pub local_plugin_path: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginShareDiscoverability {
#[serde(rename = "LISTED")]
#[ts(rename = "LISTED")]
Listed,
#[serde(rename = "UNLISTED")]
#[ts(rename = "UNLISTED")]
Unlisted,
#[serde(rename = "PRIVATE")]
#[ts(rename = "PRIVATE")]
Private,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginSharePrincipalType {
#[serde(rename = "user")]
#[ts(rename = "user")]
User,
#[serde(rename = "group")]
#[ts(rename = "group")]
Group,
#[serde(rename = "workspace")]
#[ts(rename = "workspace")]
Workspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareTarget {
pub principal_type: PluginSharePrincipalType,
pub principal_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSharePrincipal {
pub principal_type: PluginSharePrincipalType,
pub principal_id: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -2981,11 +2981,15 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
serde_json::to_value(PluginShareSaveParams {
plugin_path: plugin_path.clone(),
remote_plugin_id: None,
discoverability: None,
share_targets: None,
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": null,
"discoverability": null,
"shareTargets": null,
}),
);
@@ -2993,11 +2997,33 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
serde_json::to_value(PluginShareSaveParams {
plugin_path,
remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),),
discoverability: Some(PluginShareDiscoverability::Private),
share_targets: Some(vec![
PluginShareTarget {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
},
PluginShareTarget {
principal_type: PluginSharePrincipalType::Workspace,
principal_id: "workspace-1".to_string(),
},
]),
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
{
"principalType": "workspace",
"principalId": "workspace-1",
},
],
}),
);
@@ -3013,6 +3039,42 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
}),
);
assert_eq!(
serde_json::to_value(PluginShareUpdateTargetsParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
share_targets: vec![PluginShareTarget {
principal_type: PluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
}],
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"shareTargets": [{
"principalType": "group",
"principalId": "group-1",
}],
}),
);
assert_eq!(
serde_json::to_value(PluginShareUpdateTargetsResponse {
principals: vec![PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
}],
})
.unwrap(),
json!({
"principals": [{
"principalType": "user",
"principalId": "user-1",
"name": "Gavin",
}],
}),
);
assert_eq!(
serde_json::from_value::<PluginShareListParams>(json!({})).unwrap(),
PluginShareListParams {},
@@ -1087,6 +1087,11 @@ impl MessageProcessor {
ClientRequest::PluginShareSave { params, .. } => {
self.plugin_processor.plugin_share_save(params).await
}
ClientRequest::PluginShareUpdateTargets { params, .. } => {
self.plugin_processor
.plugin_share_update_targets(params)
.await
}
ClientRequest::PluginShareList { params, .. } => {
self.plugin_processor.plugin_share_list(params).await
}
@@ -115,11 +115,17 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareDiscoverability;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginSharePrincipal;
use codex_app_server_protocol::PluginSharePrincipalType;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginShareTarget;
use codex_app_server_protocol::PluginShareUpdateTargetsParams;
use codex_app_server_protocol::PluginShareUpdateTargetsResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
@@ -86,6 +86,66 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS
}
}
fn remote_plugin_share_discoverability(
discoverability: PluginShareDiscoverability,
) -> codex_core_plugins::remote::RemotePluginShareDiscoverability {
match discoverability {
PluginShareDiscoverability::Listed => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed
}
PluginShareDiscoverability::Unlisted => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted
}
PluginShareDiscoverability::Private => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Private
}
}
}
fn remote_plugin_share_targets(
targets: Vec<PluginShareTarget>,
) -> Vec<codex_core_plugins::remote::RemotePluginShareTarget> {
targets
.into_iter()
.map(
|target| codex_core_plugins::remote::RemotePluginShareTarget {
principal_type: match target.principal_type {
PluginSharePrincipalType::User => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User
}
PluginSharePrincipalType::Group => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group
}
PluginSharePrincipalType::Workspace => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace
}
},
principal_id: target.principal_id,
},
)
.collect()
}
fn plugin_share_principal_from_remote(
principal: codex_core_plugins::remote::RemotePluginSharePrincipal,
) -> PluginSharePrincipal {
PluginSharePrincipal {
principal_type: match principal.principal_type {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User => {
PluginSharePrincipalType::User
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => {
PluginSharePrincipalType::Group
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => {
PluginSharePrincipalType::Workspace
}
},
principal_id: principal.principal_id,
name: principal.name,
}
}
impl PluginRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
@@ -141,6 +201,15 @@ impl PluginRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_update_targets(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_update_targets_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_list(
&self,
params: PluginShareListParams,
@@ -570,22 +639,34 @@ impl PluginRequestProcessor {
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
discoverability,
share_targets,
} = params;
if let Some(remote_plugin_id) = remote_plugin_id.as_ref()
&& (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id))
{
return Err(invalid_request("invalid remote plugin id"));
}
if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) {
return Err(invalid_request(
"discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets",
));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy {
discoverability: discoverability.map(remote_plugin_share_discoverability),
share_targets: share_targets.map(remote_plugin_share_targets),
};
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&plugin_path,
remote_plugin_id.as_deref(),
access_policy,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
@@ -597,6 +678,42 @@ impl PluginRequestProcessor {
})
}
async fn plugin_share_update_targets_response(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let PluginShareUpdateTargetsParams {
remote_plugin_id,
share_targets,
} = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::update_remote_plugin_share_targets(
&remote_plugin_service_config,
auth.as_ref(),
&remote_plugin_id,
remote_plugin_share_targets(share_targets),
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets")
})?;
self.clear_plugin_related_caches();
Ok(PluginShareUpdateTargetsResponse {
principals: result
.principals
.into_iter()
.map(plugin_share_principal_from_remote)
.collect(),
})
}
async fn plugin_share_list_response(
&self,
_params: PluginShareListParams,
@@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
@@ -14,7 +15,10 @@ use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginSharePrincipal;
use codex_app_server_protocol::PluginSharePrincipalType;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginShareUpdateTargetsResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
@@ -174,6 +178,154 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_save_forwards_access_policy() -> 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;
write_remote_plugin_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,
)?;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_123",
"upload_url": format!("{}/upload/file_123", server.uri()),
"etag": "\"upload_etag_123\"",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace"))
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
"discoverability": "PRIVATE",
"share_targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "workspace",
"principal_id": "workspace-1",
},
],
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": expected_plugin_path,
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
{
"principalType": "workspace",
"principalId": "workspace-1",
},
],
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareSaveResponse = to_response(response)?;
assert_eq!(
response,
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> 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;
write_remote_plugin_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,
)?;
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)?,
"remotePluginId": "plugins_123",
"discoverability": "PRIVATE",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
],
})),
)
.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,
"discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets"
);
Ok(())
}
#[tokio::test]
async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -250,6 +402,82 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_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,
)?;
Mock::given(method("PUT"))
.and(path("/backend-api/public/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "user-1",
"name": "Gavin",
},
],
})))
.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_raw_request(
"plugin/share/updateTargets",
Some(json!({
"remotePluginId": "plugins_123",
"shareTargets": [
{
"principalType": "user",
"principalId": "user-1",
},
],
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareUpdateTargetsResponse = to_response(response)?;
assert_eq!(
response,
PluginShareUpdateTargetsResponse {
principals: vec![PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
}],
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
+7
View File
@@ -29,10 +29,17 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
pub use share::RemotePluginShareAccessPolicy;
pub use share::RemotePluginShareDiscoverability;
pub use share::RemotePluginSharePrincipal;
pub use share::RemotePluginSharePrincipalType;
pub use share::RemotePluginShareSaveResult;
pub use share::RemotePluginShareTarget;
pub use share::RemotePluginShareUpdateTargetsResult;
pub use share::delete_remote_plugin_share;
pub use share::list_remote_plugin_shares;
pub use share::save_remote_plugin_share;
pub use share::update_remote_plugin_share_targets;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";
+75
View File
@@ -26,6 +26,46 @@ pub struct RemotePluginShareSaveResult {
pub share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RemotePluginShareAccessPolicy {
pub discoverability: Option<RemotePluginShareDiscoverability>,
pub share_targets: Option<Vec<RemotePluginShareTarget>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemotePluginShareDiscoverability {
Listed,
Unlisted,
Private,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemotePluginSharePrincipalType {
User,
Group,
Workspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemotePluginShareTarget {
pub principal_type: RemotePluginSharePrincipalType,
pub principal_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginSharePrincipal {
pub principal_type: RemotePluginSharePrincipalType,
pub principal_id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareUpdateTargetsResult {
pub principals: Vec<RemotePluginSharePrincipal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginUploadUrlRequest<'a> {
filename: &'a str,
@@ -46,6 +86,10 @@ struct RemoteWorkspacePluginUploadUrlResponse {
struct RemoteWorkspacePluginCreateRequest {
file_id: String,
etag: String,
#[serde(skip_serializing_if = "Option::is_none")]
discoverability: Option<RemotePluginShareDiscoverability>,
#[serde(skip_serializing_if = "Option::is_none")]
share_targets: Option<Vec<RemotePluginShareTarget>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -54,12 +98,23 @@ struct RemoteWorkspacePluginCreateResponse {
share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemotePluginShareUpdateTargetsRequest {
targets: Vec<RemotePluginShareTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginShareUpdateTargetsResponse {
principals: Vec<RemotePluginSharePrincipal>,
}
pub async fn save_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
codex_home: &Path,
plugin_path: &AbsolutePathBuf,
remote_plugin_id: Option<&str>,
access_policy: RemotePluginShareAccessPolicy,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
@@ -89,6 +144,8 @@ pub async fn save_remote_plugin_share(
RemoteWorkspacePluginCreateRequest {
file_id: upload.file_id,
etag,
discoverability: access_policy.discoverability,
share_targets: access_policy.share_targets,
},
)
.await?;
@@ -173,6 +230,24 @@ pub async fn delete_remote_plugin_share(
Ok(())
}
pub async fn update_remote_plugin_share_targets(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
targets: Vec<RemotePluginShareTarget>,
) -> Result<RemotePluginShareUpdateTargetsResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares");
let client = build_reqwest_client();
let request = authenticated_request(client.put(&url), auth)?
.json(&RemotePluginShareUpdateTargetsRequest { targets });
let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?;
Ok(RemotePluginShareUpdateTargetsResult {
principals: response.principals,
})
}
async fn fetch_created_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
@@ -202,6 +202,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
"discoverability": "PRIVATE",
"share_targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "workspace",
"principal_id": "workspace-1",
},
],
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
@@ -217,6 +228,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
codex_home.path(),
&plugin_path,
/*remote_plugin_id*/ None,
RemotePluginShareAccessPolicy {
discoverability: Some(RemotePluginShareDiscoverability::Private),
share_targets: Some(vec![
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
},
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::Workspace,
principal_id: "workspace-1".to_string(),
},
]),
},
)
.await
.unwrap();
@@ -354,6 +378,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
codex_home.path(),
&plugin_path,
Some("plugins_123"),
RemotePluginShareAccessPolicy::default(),
)
.await
.unwrap();
@@ -367,6 +392,83 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
);
}
#[tokio::test]
async fn update_remote_plugin_share_targets_updates_targets() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("PUT"))
.and(path("/backend-api/public/plugins/plugins_123/shares"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"targets": [
{
"principal_type": "user",
"principal_id": "user-1",
},
{
"principal_type": "group",
"principal_id": "group-1",
},
],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "user-1",
"name": "Gavin",
},
{
"principal_type": "group",
"principal_id": "group-1",
"name": "Engineering",
},
],
})))
.expect(1)
.mount(&server)
.await;
let result = update_remote_plugin_share_targets(
&config,
Some(&auth),
"plugins_123",
vec![
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
},
RemotePluginShareTarget {
principal_type: RemotePluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
},
],
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareUpdateTargetsResult {
principals: vec![
RemotePluginSharePrincipal {
principal_type: RemotePluginSharePrincipalType::User,
principal_id: "user-1".to_string(),
name: "Gavin".to_string(),
},
RemotePluginSharePrincipal {
principal_type: RemotePluginSharePrincipalType::Group,
principal_id: "group-1".to_string(),
name: "Engineering".to_string(),
},
],
}
);
}
#[tokio::test]
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
let codex_home = TempDir::new().unwrap();