diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cb14a3a91a..6351993046 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2088,14 +2088,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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 0130e97653..215842d929 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 @@ -12270,10 +12270,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" }, @@ -12334,14 +12344,10 @@ }, "plugin": { "$ref": "#/definitions/v2/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -12376,15 +12382,27 @@ }, "principalType": { "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -12455,14 +12473,25 @@ }, "principalType": { "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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 66180b11e1..085744e819 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 @@ -8819,10 +8819,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -8883,14 +8893,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -8925,15 +8931,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -9004,14 +9022,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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 b759d7a3fe..473d1a2a5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -246,10 +246,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -270,6 +280,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginSharePrincipal": { "properties": { "name": { @@ -280,15 +298,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index fe468884f1..d4473d96ac 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -300,10 +300,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -324,6 +334,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginSharePrincipal": { "properties": { "name": { @@ -334,15 +352,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 96818dfead..ae70ed5cdd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -181,10 +181,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -205,6 +215,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -219,14 +237,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -240,15 +254,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json index c269223068..7ff4ac18ef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -28,13 +28,24 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json index f6b44c92eb..38a7d8d29f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -16,14 +16,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json index fe47f1f4af..4923be498a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -19,15 +19,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts index f1c5c958d7..86d610bf5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; import type { PluginSharePrincipal } from "./PluginSharePrincipal"; -export type PluginShareContext = { remotePluginId: string, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, shareTargets: Array | null, }; +export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts index b63738aacd..aa5aa4ee4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts @@ -4,4 +4,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { PluginSummary } from "./PluginSummary"; -export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, }; +export type PluginShareListItem = { plugin: PluginSummary, localPluginPath: AbsolutePathBuf | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts index 9e0ecc48e7..dd0dff2009 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalRole } from "./PluginSharePrincipalRole"; import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; -export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, role: PluginSharePrincipalRole, name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.ts new file mode 100644 index 0000000000..0a022a0bcd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.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 PluginSharePrincipalRole = "reader" | "editor" | "owner"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts index fd1969087f..66d22ef4a6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.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 { PluginSharePrincipalType } from "./PluginSharePrincipalType"; +import type { PluginShareTargetRole } from "./PluginShareTargetRole"; -export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, }; +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, role: PluginShareTargetRole, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts new file mode 100644 index 0000000000..95eee17be0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.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 PluginShareTargetRole = "reader" | "editor"; 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 7c431f9ec3..a6b961366e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -285,10 +285,12 @@ export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalRole } from "./PluginSharePrincipalRole"; export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareTargetRole } from "./PluginShareTargetRole"; export type { PluginShareUpdateDiscoverability } from "./PluginShareUpdateDiscoverability"; export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 6f425b4a6a..ed03cc6ff3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -259,7 +259,6 @@ pub struct PluginShareDeleteResponse {} #[ts(export_to = "v2/")] pub struct PluginShareListItem { pub plugin: PluginSummary, - pub share_url: String, pub local_plugin_path: Option, } @@ -308,6 +307,7 @@ pub enum PluginSharePrincipalType { pub struct PluginShareTarget { pub principal_type: PluginSharePrincipalType, pub principal_id: String, + pub role: PluginShareTargetRole, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -316,9 +316,29 @@ pub struct PluginShareTarget { pub struct PluginSharePrincipal { pub principal_type: PluginSharePrincipalType, pub principal_id: String, + pub role: PluginSharePrincipalRole, pub name: String, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum PluginShareTargetRole { + Reader, + Editor, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalRole { + Reader, + Editor, + Owner, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -539,10 +559,11 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginShareContext { pub remote_plugin_id: String, + pub discoverability: Option, pub share_url: Option, pub creator_account_user_id: Option, pub creator_name: Option, - pub share_targets: Option>, + pub share_principals: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index da0ad2c10e..30599776ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2896,10 +2896,12 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { PluginShareTarget { principal_type: PluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: PluginShareTargetRole::Reader, }, PluginShareTarget { - principal_type: PluginSharePrincipalType::Workspace, - principal_id: "workspace-1".to_string(), + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Reader, }, ]), }) @@ -2912,10 +2914,12 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { { "principalType": "user", "principalId": "user-1", + "role": "reader", }, { - "principalType": "workspace", - "principalId": "workspace-1", + "principalType": "group", + "principalId": "group-1", + "role": "reader", }, ], }), @@ -2940,6 +2944,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { share_targets: vec![PluginShareTarget { principal_type: PluginSharePrincipalType::Group, principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Editor, }], }) .unwrap(), @@ -2949,6 +2954,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { "shareTargets": [{ "principalType": "group", "principalId": "group-1", + "role": "editor", }], }), ); @@ -2958,6 +2964,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { principals: vec![PluginSharePrincipal { principal_type: PluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: PluginSharePrincipalRole::Owner, name: "Gavin".to_string(), }], discoverability: PluginShareDiscoverability::Unlisted, @@ -2967,6 +2974,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { "principals": [{ "principalType": "user", "principalId": "user-1", + "role": "owner", "name": "Gavin", }], "discoverability": "UNLISTED", @@ -3007,7 +3015,6 @@ fn plugin_share_list_response_serializes_share_items() { interface: None, keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], }) @@ -3027,7 +3034,6 @@ fn plugin_share_list_response_serializes_share_items() { "interface": null, "keywords": [], }, - "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", "localPluginPath": null, }], }), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 65bb390851..23d76d55b7 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -3,6 +3,8 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginSharePrincipalRole; +use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -88,13 +90,14 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS fn load_shared_plugin_ids_by_local_path( config: &Config, -) -> std::collections::BTreeMap { +) -> Result, JSONRPCErrorError> { codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path( config.codex_home.as_path(), ) - .unwrap_or_else(|err| { - warn!("failed to load plugin share local path mapping: {err}"); - std::collections::BTreeMap::new() + .map_err(|err| { + internal_error(format!( + "failed to load plugin share local path mapping: {err}" + )) }) } @@ -108,10 +111,11 @@ fn share_context_for_source( .cloned() .map(|remote_plugin_id| PluginShareContext { remote_plugin_id, + discoverability: None, share_url: None, creator_account_user_id: None, creator_name: None, - share_targets: None, + share_principals: None, }), MarketplacePluginSource::Git { .. } => None, } @@ -160,6 +164,35 @@ fn validate_client_plugin_share_targets( Ok(()) } +fn remote_plugin_share_target_role( + role: PluginShareTargetRole, +) -> codex_core_plugins::remote::RemotePluginShareTargetRole { + match role { + PluginShareTargetRole::Reader => { + codex_core_plugins::remote::RemotePluginShareTargetRole::Reader + } + PluginShareTargetRole::Editor => { + codex_core_plugins::remote::RemotePluginShareTargetRole::Editor + } + } +} + +fn plugin_share_principal_role_from_remote( + role: codex_core_plugins::remote::RemotePluginSharePrincipalRole, +) -> PluginSharePrincipalRole { + match role { + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Reader => { + PluginSharePrincipalRole::Reader + } + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Editor => { + PluginSharePrincipalRole::Editor + } + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Owner => { + PluginSharePrincipalRole::Owner + } + } +} + fn remote_plugin_share_targets( targets: Vec, ) -> Vec { @@ -179,6 +212,7 @@ fn remote_plugin_share_targets( } }, principal_id: target.principal_id, + role: remote_plugin_share_target_role(target.role), }, ) .collect() @@ -200,6 +234,7 @@ fn plugin_share_principal_from_remote( } }, principal_id: principal.principal_id, + role: plugin_share_principal_role_from_remote(principal.role), name: principal.name, } } @@ -415,7 +450,7 @@ impl PluginRequestProcessor { let config_for_marketplace_listing = plugins_input.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?; match tokio::task::spawn_blocking(move || { let outcome = plugins_manager_for_marketplace_listing .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; @@ -526,10 +561,25 @@ impl PluginRequestProcessor { } } } + Err( + err @ (RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode), + ) if explicit_marketplace_kinds => { + return Err(remote_plugin_catalog_error_to_jsonrpc( + err, + "list remote plugin catalog", + )); + } Err( RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode, ) => {} + Err(err) if explicit_marketplace_kinds => { + return Err(remote_plugin_catalog_error_to_jsonrpc( + err, + "list remote plugin catalog", + )); + } Err(err) => { warn!( error = %err, @@ -603,11 +653,56 @@ impl PluginRequestProcessor { .read_plugin_for_config(&plugins_input, &request) .await .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; - let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + let shared_plugin_ids_by_local_path = + load_shared_plugin_ids_by_local_path(&config)?; let share_context = share_context_for_source( &outcome.plugin.source, &shared_plugin_ids_by_local_path, ); + let share_context = match share_context { + Some(context) => { + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_remote_plugin_share_context( + &remote_plugin_service_config, + auth.as_ref(), + &context.remote_plugin_id, + ) + .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(None) => { + warn!( + remote_plugin_id = %context.remote_plugin_id, + "remote shared plugin detail did not include share context; returning local share mapping context" + ); + Some(context) + } + Err(err) => { + warn!( + remote_plugin_id = %context.remote_plugin_id, + error = %err, + "failed to hydrate local plugin share context; returning local share mapping context" + ); + Some(context) + } + } + } + None => None, + }; let environment_manager = self.thread_manager.environment_manager(); let app_summaries = load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager) @@ -807,7 +902,6 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } validate_client_plugin_share_targets(&share_targets)?; - let requested_share_targets = share_targets.clone(); let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), @@ -829,12 +923,6 @@ impl PluginRequestProcessor { .principals .into_iter() .map(plugin_share_principal_from_remote) - .filter(|principal| { - requested_share_targets.iter().any(|target| { - target.principal_type == principal.principal_type - && target.principal_id == principal.principal_id - }) - }) .collect(), discoverability: remote_plugin_share_discoverability_to_info(result.discoverability), }) @@ -859,13 +947,11 @@ impl PluginRequestProcessor { .map(|summary| { let RemoteCatalogPluginShareSummary { summary, - share_url, local_plugin_path, } = summary; let plugin = remote_plugin_summary_to_info(summary); PluginShareListItem { plugin, - share_url: share_url.unwrap_or_default(), local_plugin_path, } }) @@ -1521,11 +1607,14 @@ fn remote_plugin_share_context_to_info( ) -> PluginShareContext { PluginShareContext { remote_plugin_id: context.remote_plugin_id, + discoverability: Some(remote_plugin_share_discoverability_to_info( + context.discoverability, + )), share_url: context.share_url, creator_account_user_id: context.creator_account_user_id, creator_name: context.creator_name, - share_targets: context.share_targets.map(|targets| { - targets + share_principals: context.share_principals.map(|principals| { + principals .into_iter() .map(plugin_share_principal_from_remote) .collect() 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 ea8294671b..a0205cb059 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -13,8 +13,7 @@ use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; -use codex_app_server_protocol::PluginSharePrincipal; -use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -694,10 +693,11 @@ async fn plugin_list_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.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_targets, None); + assert_eq!(share_context.share_principals, None); Ok(()) } @@ -1680,12 +1680,15 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let shared_plugin_body = workspace_remote_plugin_page_body( - "plugins~Plugin_22222222222222222222222222222222", - "shared-linear", - "Shared Linear", - /*enabled*/ None, - ); + let mut shared_plugin_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + /*enabled*/ None, + ))?; + shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; + let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?; let workspace_installed_body = workspace_remote_plugin_page_body( "plugins~Plugin_22222222222222222222222222222222", "shared-linear", @@ -1734,6 +1737,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.remote_plugin_id, "plugins~Plugin_22222222222222222222222222222222" ); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); assert_eq!( share_context.creator_account_user_id.as_deref(), Some("user-gavin__account-123") @@ -1743,14 +1750,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.share_url.as_deref(), Some("https://chatgpt.example/plugins/share/share-key-1") ); - assert_eq!( - share_context.share_targets, - Some(vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-ada__account-123".to_string(), - name: "Ada".to_string(), - }]) - ); + assert_eq!(share_context.share_principals, None); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } @@ -2275,6 +2275,7 @@ fn workspace_remote_plugin_page_body( "id": "{remote_plugin_id}", "name": "{plugin_name}", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "creator_account_user_id": "user-gavin__account-123", "share_url": "https://chatgpt.example/plugins/share/share-key-1", "installation_policy": "AVAILABLE", diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 16924b0218..769749b34b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -18,12 +18,15 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginSkillReadResponse; @@ -237,6 +240,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< "id": "plugins~Plugin_11111111111111111111111111111111", "name": "shared-linear", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "creator_account_user_id": "user-gavin__account-123", "creator_name": "Gavin", "share_url": "https://chatgpt.example/plugins/share/share-key-1", @@ -319,6 +323,10 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< share_context.remote_plugin_id, "plugins~Plugin_11111111111111111111111111111111" ); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); assert_eq!( share_context.creator_account_user_id.as_deref(), Some("user-gavin__account-123") @@ -329,12 +337,21 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< Some("https://chatgpt.example/plugins/share/share-key-1") ); assert_eq!( - share_context.share_targets, - Some(vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-ada__account-123".to_string(), - name: "Ada".to_string(), - }]) + share_context.share_principals, + Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-gavin__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Gavin".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-ada__account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Ada".to_string(), + }, + ]) ); Ok(()) } @@ -751,6 +768,19 @@ enabled = true async fn plugin_read_returns_share_context_for_shared_local_plugin() -> 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", @@ -764,7 +794,120 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .join("demo-plugin/.codex-plugin/plugin.json"), r#"{"name":"demo-plugin"}"#, )?; + 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": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-editor__account-123", + "role": "editor", + "name": "Editor", + }, + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "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)?; + + 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.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-owner__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Owner")); + assert_eq!( + share_context.share_principals, + Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-owner__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-editor__account-123".to_string(), + role: PluginSharePrincipalRole::Editor, + name: "Editor".to_string(), + }, + ]) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; write_plugins_enabled_config(&codex_home)?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + write_plugin_source(repo_root.path(), "demo-plugin", &[])?; 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)?; @@ -795,10 +938,60 @@ 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.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_targets, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugins_enabled_config(&codex_home)?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + write_plugin_source(repo_root.path(), "demo-plugin", &[])?; + std::fs::create_dir_all(codex_home.path().join(".tmp"))?; + std::fs::write( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + "not valid json\n", + )?; + + 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 error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32603); + assert!( + error + .error + .message + .contains("failed to load plugin share local path mapping") + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index dc1f56d487..b081017ad4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -14,9 +14,11 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveResponse; use codex_app_server_protocol::PluginShareUpdateTargetsResponse; @@ -172,7 +174,6 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: Some(expected_plugin_path), }], } @@ -224,10 +225,12 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "workspace", "principal_id": "account-123", + "role": "reader", }, ], }))) @@ -252,6 +255,7 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { { "principalType": "user", "principalId": "user-1", + "role": "editor", }, ], })), @@ -345,6 +349,7 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { { "principalType": "workspace", "principalId": "account-123", + "role": "reader", }, ], })), @@ -373,6 +378,7 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { { "principalType": "workspace", "principalId": "account-123", + "role": "reader", }, ], })), @@ -422,6 +428,7 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result { "principalType": "user", "principalId": "user-1", + "role": "reader", }, ], })), @@ -511,7 +518,6 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -543,10 +549,12 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "workspace", "principal_id": "account-123", + "role": "reader", }, ], }))) @@ -555,19 +563,23 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "principal_type": "user", "principal_id": "owner-1", + "role": "owner", "name": "Owner", }, { "principal_type": "user", "principal_id": "user-1", + "role": "editor", "name": "Gavin", }, { "principal_type": "workspace", "principal_id": "account-123", + "role": "reader", "name": "Workspace", }, ], + "discoverability": "UNLISTED", }))) .expect(1) .mount(&server) @@ -585,6 +597,7 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "principalType": "user", "principalId": "user-1", + "role": "editor", }, ], })), @@ -601,11 +614,26 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { assert_eq!( response, PluginShareUpdateTargetsResponse { - principals: vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - name: "Gavin".to_string(), - }], + principals: vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "owner-1".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: PluginSharePrincipalRole::Editor, + name: "Gavin".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Workspace".to_string(), + }, + ], discoverability: codex_app_server_protocol::PluginShareDiscoverability::Unlisted, } ); @@ -709,7 +737,6 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -737,7 +764,22 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "id": plugin_id, "name": "demo-plugin", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner" + }, + { + "principal_type": "user", + "principal_id": "user-reader__account-123", + "role": "reader", + "name": "Reader" + } + ], "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { @@ -793,10 +835,24 @@ fn expected_plugin_interface() -> PluginInterface { fn expected_share_context(plugin_id: &str) -> PluginShareContext { PluginShareContext { remote_plugin_id: plugin_id.to_string(), + discoverability: Some(PluginShareDiscoverability::Private), share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), creator_account_user_id: None, creator_name: None, - share_targets: None, + share_principals: Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-owner__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-reader__account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Reader".to_string(), + }, + ]), } } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4316976284..ed05053045 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -32,9 +32,11 @@ 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::RemotePluginSharePrincipalRole; pub use share::RemotePluginSharePrincipalType; pub use share::RemotePluginShareSaveResult; pub use share::RemotePluginShareTarget; +pub use share::RemotePluginShareTargetRole; pub use share::RemotePluginShareUpdateDiscoverability; pub use share::RemotePluginShareUpdateTargetsResult; pub use share::delete_remote_plugin_share; @@ -99,16 +101,16 @@ pub struct RemotePluginSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareContext { pub remote_plugin_id: String, + pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option, pub creator_account_user_id: Option, pub creator_name: Option, - pub share_targets: Option>, + pub share_principals: Option>, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, - pub share_url: Option, pub local_plugin_path: Option, } @@ -361,6 +363,8 @@ struct RemotePluginDirectoryItem { name: String, scope: RemotePluginScope, #[serde(default)] + discoverability: Option, + #[serde(default)] creator_account_user_id: Option, #[serde(default)] creator_name: Option, @@ -379,8 +383,7 @@ struct RemotePluginDirectoryItem { struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, principal_id: String, - #[serde(default)] - role: Option, + role: RemotePluginSharePrincipalRole, name: String, } @@ -444,7 +447,7 @@ pub async fn fetch_remote_marketplaces( directory_plugins, installed_plugins, /*include_installed_only*/ true, - ) + )? } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; @@ -456,7 +459,7 @@ pub async fn fetch_remote_marketplaces( directory_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - ) + )? } RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, @@ -464,7 +467,7 @@ pub async fn fetch_remote_marketplaces( fetch_shared_workspace_plugins(config, auth).await?, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - ), + )?, }; if let Some(marketplace) = marketplace { marketplaces.push(marketplace); @@ -480,7 +483,7 @@ fn build_remote_marketplace( directory_plugins: Vec, installed_plugins: Vec, include_installed_only: bool, -) -> Option { +) -> Result, RemotePluginCatalogError> { let directory_plugins = directory_plugins .into_iter() .map(|plugin| (plugin.id.clone(), plugin)) @@ -500,7 +503,7 @@ fn build_remote_marketplace( .cloned() .collect::>(); if plugin_ids.is_empty() { - return None; + return Ok(None); } let mut plugins = plugin_ids @@ -510,9 +513,10 @@ fn build_remote_marketplace( let installed_plugin = installed_plugins.get(&plugin_id); directory_plugin .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) - .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) + .map(|plugin| (plugin, installed_plugin)) }) - .collect::>(); + .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) + .collect::, _>>()?; plugins.sort_by(|left, right| { remote_plugin_display_name(left) .to_ascii_lowercase() @@ -520,11 +524,11 @@ fn build_remote_marketplace( .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) .then_with(|| left.id.cmp(&right.id)) }); - Some(RemoteMarketplace { + Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), plugins, - }) + })) } pub async fn fetch_remote_installed_plugins( @@ -576,6 +580,19 @@ pub async fn fetch_remote_plugin_detail( .await } +pub async fn fetch_remote_plugin_share_context( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail( + config, auth, plugin_id, /*include_download_urls*/ false, + ) + .await?; + remote_plugin_share_context(&plugin) +} + pub async fn fetch_remote_plugin_detail_with_download_urls( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -687,7 +704,7 @@ async fn build_remote_plugin_detail( Ok(RemotePluginDetail { marketplace_name, marketplace_display_name: scope.marketplace_display_name().to_string(), - summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), + summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?, description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, @@ -823,11 +840,11 @@ fn remove_remote_plugin_cache( fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, -) -> RemotePluginSummary { - RemotePluginSummary { +) -> Result { + Ok(RemotePluginSummary { id: plugin.id.clone(), name: plugin.name.clone(), - share_context: remote_plugin_share_context(plugin), + share_context: remote_plugin_share_context(plugin)?, installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, @@ -835,31 +852,40 @@ fn build_remote_plugin_summary( availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), - } + }) } fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, -) -> Option { +) -> Result, RemotePluginCatalogError> { match plugin.scope { - RemotePluginScope::Global => None, - RemotePluginScope::Workspace => Some(RemotePluginShareContext { - remote_plugin_id: plugin.id.clone(), - share_url: plugin.share_url.clone(), - creator_account_user_id: plugin.creator_account_user_id.clone(), - creator_name: plugin.creator_name.clone(), - share_targets: plugin.share_principals.as_ref().map(|principals| { - principals - .iter() - .filter(|principal| principal.role.as_deref() == Some("reader")) - .map(|principal| RemotePluginSharePrincipal { - principal_type: principal.principal_type, - principal_id: principal.principal_id.clone(), - name: principal.name.clone(), - }) - .collect() - }), - }), + RemotePluginScope::Global => Ok(None), + RemotePluginScope::Workspace => { + let discoverability = plugin.discoverability.ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "workspace plugin `{}` did not include discoverability", + plugin.id + )) + })?; + Ok(Some(RemotePluginShareContext { + remote_plugin_id: plugin.id.clone(), + discoverability, + share_url: plugin.share_url.clone(), + creator_account_user_id: plugin.creator_account_user_id.clone(), + creator_name: plugin.creator_name.clone(), + share_principals: plugin.share_principals.as_ref().map(|share_principals| { + share_principals + .iter() + .map(|principal| RemotePluginSharePrincipal { + principal_type: principal.principal_type, + principal_id: principal.principal_id.clone(), + role: principal.role, + name: principal.name.clone(), + }) + .collect() + }), + })) + } } } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index d69d22ea52..6afeab74b1 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -59,15 +59,32 @@ pub enum RemotePluginSharePrincipalType { pub struct RemotePluginShareTarget { pub principal_type: RemotePluginSharePrincipalType, pub principal_id: String, + pub role: RemotePluginShareTargetRole, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct RemotePluginSharePrincipal { pub principal_type: RemotePluginSharePrincipalType, pub principal_id: String, + pub role: RemotePluginSharePrincipalRole, pub name: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginShareTargetRole { + Reader, + Editor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalRole { + Reader, + Editor, + Owner, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareUpdateTargetsResult { pub principals: Vec, @@ -115,7 +132,7 @@ struct RemotePluginShareUpdateTargetsRequest { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginShareUpdateTargetsResponse { principals: Vec, - discoverability: Option, + discoverability: RemotePluginShareDiscoverability, } pub async fn save_remote_plugin_share( @@ -203,33 +220,54 @@ pub async fn list_remote_plugin_shares( .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::>(); let local_plugin_paths = - local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| { - warn!("failed to load plugin share local path mapping: {err}"); - BTreeMap::new() - }); + local_paths::load_plugin_share_local_paths(codex_home).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to load plugin share local path mapping: {err}" + )) + })?; - Ok(created_plugins + created_plugins .into_iter() .map(|plugin| { - let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)); - let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); - RemotePluginShareSummary { - summary, - share_url: plugin.share_url, - local_plugin_path, + let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id))?; + if summary + .share_context + .as_ref() + .and_then(|context| context.share_principals.as_ref()) + .is_none() + { + return Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "created workspace plugin `{}` did not include share_principals", + plugin.id + ))); } + let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); + Ok(RemotePluginShareSummary { + summary, + local_plugin_path, + }) }) - .collect()) + .collect() } pub fn load_plugin_share_remote_ids_by_local_path( codex_home: &Path, ) -> io::Result> { let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?; - Ok(local_paths + local_paths .into_iter() - .map(|(remote_plugin_id, local_plugin_path)| (local_plugin_path, remote_plugin_id)) - .collect()) + .map(|(remote_plugin_id, local_plugin_path)| { + if !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid remote plugin id in share local path mapping: {remote_plugin_id}" + ), + )); + } + Ok((local_plugin_path, remote_plugin_id)) + }) + .collect() } pub async fn delete_remote_plugin_share( @@ -284,9 +322,7 @@ pub async fn update_remote_plugin_share_targets( let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; Ok(RemotePluginShareUpdateTargetsResult { principals: response.principals, - // TODO: Remove this fallback once deployed plugin-service responses always include - // discoverability per the API schema. - discoverability: response.discoverability.unwrap_or(target_discoverability), + discoverability: response.discoverability, }) } @@ -311,6 +347,7 @@ fn ensure_unlisted_workspace_target( targets.push(RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::Workspace, principal_id: account_id, + role: RemotePluginShareTargetRole::Reader, }); } Ok(Some(targets)) diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 35909a8b19..8791cde597 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -116,6 +116,7 @@ fn remote_plugin_json_with_share_url_and_principals( let serde_json::Value::Object(fields) = &mut plugin else { unreachable!("plugin json should be an object"); }; + fields.insert("discoverability".to_string(), json!("PRIVATE")); fields.insert("share_url".to_string(), json!(share_url)); fields.insert("share_principals".to_string(), share_principals); plugin @@ -209,10 +210,12 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { { "principal_type": "user", "principal_id": "user-1", + "role": "reader", }, { "principal_type": "workspace", "principal_id": "account_id", + "role": "reader", }, ], }))) @@ -235,6 +238,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { share_targets: Some(vec![RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Reader, }]), }, ) @@ -404,14 +408,17 @@ async fn update_remote_plugin_share_targets_updates_targets() { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "group", "principal_id": "group-1", + "role": "reader", }, { "principal_type": "workspace", "principal_id": "account_id", + "role": "reader", }, ], }))) @@ -420,11 +427,13 @@ async fn update_remote_plugin_share_targets_updates_targets() { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", "name": "Gavin", }, { "principal_type": "group", "principal_id": "group-1", + "role": "reader", "name": "Engineering", }, ], @@ -442,10 +451,12 @@ async fn update_remote_plugin_share_targets_updates_targets() { RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Editor, }, RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::Group, principal_id: "group-1".to_string(), + role: RemotePluginShareTargetRole::Reader, }, ], RemotePluginShareUpdateDiscoverability::Unlisted, @@ -460,11 +471,13 @@ async fn update_remote_plugin_share_targets_updates_targets() { RemotePluginSharePrincipal { principal_type: RemotePluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: RemotePluginSharePrincipalRole::Editor, name: "Gavin".to_string(), }, RemotePluginSharePrincipal { principal_type: RemotePluginSharePrincipalType::Group, principal_id: "group-1".to_string(), + role: RemotePluginSharePrincipalRole::Reader, name: "Engineering".to_string(), }, ], @@ -473,64 +486,6 @@ async fn update_remote_plugin_share_targets_updates_targets() { ); } -#[tokio::test] -async fn update_remote_plugin_share_targets_falls_back_to_requested_discoverability() { - let server = MockServer::start().await; - let config = test_config(&server); - let auth = test_auth(); - - Mock::given(method("PUT")) - .and(path("/backend-api/ps/plugins/plugins_123/shares")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .and(body_json(json!({ - "discoverability": "PRIVATE", - "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 result = update_remote_plugin_share_targets( - &config, - Some(&auth), - "plugins_123", - vec![RemotePluginShareTarget { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - }], - RemotePluginShareUpdateDiscoverability::Private, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginShareUpdateTargetsResult { - principals: vec![RemotePluginSharePrincipal { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - name: "Gavin".to_string(), - }], - discoverability: RemotePluginShareDiscoverability::Private, - } - ); -} - #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); @@ -602,11 +557,6 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { "role": "editor", "name": "Editor", }, - { - "principal_type": "user", - "principal_id": "user-missing-role", - "name": "Missing Role", - }, ]), )], "pagination": empty_pagination_json(), @@ -638,16 +588,26 @@ 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(), + discoverability: RemotePluginShareDiscoverability::Private, share_url: Some( "https://chatgpt.example/plugins/share/share-key-1".to_string(), ), creator_account_user_id: None, creator_name: None, - share_targets: Some(vec![RemotePluginSharePrincipal { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-reader".to_string(), - name: "Reader".to_string(), - }]), + share_principals: Some(vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-owner".to_string(), + role: RemotePluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-reader".to_string(), + role: RemotePluginSharePrincipalRole::Reader, + name: "Reader".to_string(), + }, + ]), }), installed: false, enabled: false, @@ -657,7 +617,6 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), local_plugin_path: Some(local_plugin_path), }, RemotePluginShareSummary { @@ -666,10 +625,24 @@ 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(), + discoverability: RemotePluginShareDiscoverability::Private, share_url: None, creator_account_user_id: None, creator_name: None, - share_targets: Some(Vec::new()), + share_principals: Some(vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-owner".to_string(), + role: RemotePluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-editor".to_string(), + role: RemotePluginSharePrincipalRole::Editor, + name: "Editor".to_string(), + }, + ]), }), installed: true, enabled: true, @@ -679,7 +652,6 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: None, local_plugin_path: None, } ]