feat: Add workspace plugin sharing APIs (#20278)

1. Adds v2 plugin/share/save, plugin/share/list, and plugin/share/delete
RPCs.
2. Implements save by archiving a local plugin root, enforcing a size
limit, uploading through the workspace upload flow, and supporting
updates via remotePluginId.
3. Lists created workspace plugins
4. Deletes a previously uploaded/shared plugin.
This commit is contained in:
xl-openai
2026-04-29 23:49:20 -07:00
committed by GitHub
Unverified
parent ae863e72a2
commit 87d0cf1a62
29 changed files with 2402 additions and 108 deletions
@@ -2186,6 +2186,37 @@
],
"type": "object"
},
"PluginShareDeleteParams": {
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareListParams": {
"type": "object"
},
"PluginShareSaveParams": {
"properties": {
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": [
"string",
"null"
]
}
},
"required": [
"pluginPath"
],
"type": "object"
},
"PluginUninstallParams": {
"properties": {
"pluginId": {
@@ -5075,6 +5106,78 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/save"
],
"title": "Plugin/share/saveRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareSaveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/list"
],
"title": "Plugin/share/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/delete"
],
"title": "Plugin/share/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -786,6 +786,78 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/save"
],
"title": "Plugin/share/saveRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareSaveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/list"
],
"title": "Plugin/share/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/delete"
],
"title": "Plugin/share/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12249,6 +12321,81 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareDeleteParams",
"type": "object"
},
"PluginShareDeleteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
"type": "object"
},
"PluginShareListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/PluginSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "PluginShareListResponse",
"type": "object"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pluginPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"remotePluginId": {
"type": [
"string",
"null"
]
}
},
"required": [
"pluginPath"
],
"title": "PluginShareSaveParams",
"type": "object"
},
"PluginShareSaveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"remotePluginId",
"shareUrl"
],
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -1545,6 +1545,78 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/save"
],
"title": "Plugin/share/saveRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareSaveParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/saveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/list"
],
"title": "Plugin/share/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/delete"
],
"title": "Plugin/share/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -8903,6 +8975,81 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareDeleteParams",
"type": "object"
},
"PluginShareDeleteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
"type": "object"
},
"PluginShareListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "PluginShareListResponse",
"type": "object"
},
"PluginShareSaveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": [
"string",
"null"
]
}
},
"required": [
"pluginPath"
],
"title": "PluginShareSaveParams",
"type": "object"
},
"PluginShareSaveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"remotePluginId",
"shareUrl"
],
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareDeleteParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareDeleteResponse",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
"type": "object"
}
@@ -0,0 +1,291 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"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"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
"type": [
"string",
"null"
]
},
"capabilities": {
"items": {
"type": "string"
},
"type": "array"
},
"category": {
"type": [
"string",
"null"
]
},
"composerIcon": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local composer icon path, resolved from the installed plugin package."
},
"composerIconUrl": {
"description": "Remote composer icon URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"defaultPrompt": {
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"developerName": {
"type": [
"string",
"null"
]
},
"displayName": {
"type": [
"string",
"null"
]
},
"logo": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
"null"
]
},
"privacyPolicyUrl": {
"type": [
"string",
"null"
]
},
"screenshotUrls": {
"description": "Remote screenshot URLs from the plugin catalog.",
"items": {
"type": "string"
},
"type": "array"
},
"screenshots": {
"description": "Local screenshot paths, resolved from the installed plugin package.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"shortDescription": {
"type": [
"string",
"null"
]
},
"termsOfServiceUrl": {
"type": [
"string",
"null"
]
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"capabilities",
"screenshotUrls",
"screenshots"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"local"
],
"title": "LocalPluginSourceType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalPluginSource",
"type": "object"
},
{
"properties": {
"path": {
"type": [
"string",
"null"
]
},
"refName": {
"type": [
"string",
"null"
]
},
"sha": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"git"
],
"title": "GitPluginSourceType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "GitPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
"type": {
"enum": [
"remote"
],
"title": "RemotePluginSourceType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RemotePluginSource",
"type": "object"
}
]
},
"PluginSummary": {
"properties": {
"authPolicy": {
"$ref": "#/definitions/PluginAuthPolicy"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installed": {
"type": "boolean"
},
"interface": {
"anyOf": [
{
"$ref": "#/definitions/PluginInterface"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
"source": {
"$ref": "#/definitions/PluginSource"
}
},
"required": [
"authPolicy",
"enabled",
"id",
"installPolicy",
"installed",
"name",
"source"
],
"type": "object"
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "PluginShareListResponse",
"type": "object"
}
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"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"
}
},
"properties": {
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": [
"string",
"null"
]
}
},
"required": [
"pluginPath"
],
"title": "PluginShareSaveParams",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"remotePluginId",
"shareUrl"
],
"title": "PluginShareSaveResponse",
"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 PluginShareDeleteParams = { remotePluginId: 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 PluginShareDeleteResponse = Record<string, never>;
@@ -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 PluginShareListParams = Record<string, never>;
@@ -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 { PluginSummary } from "./PluginSummary";
export type PluginShareListResponse = { data: Array<PluginSummary>, };
@@ -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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, };
@@ -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 PluginShareSaveResponse = { remotePluginId: string, shareUrl: string, };
@@ -280,6 +280,12 @@ export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
@@ -611,6 +611,21 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginReadResponse,
},
PluginShareSave => "plugin/share/save" {
params: v2::PluginShareSaveParams,
serialization: global("config"),
response: v2::PluginShareSaveResponse,
},
PluginShareList => "plugin/share/list" {
params: v2::PluginShareListParams,
serialization: global("config"),
response: v2::PluginShareListResponse,
},
PluginShareDelete => "plugin/share/delete" {
params: v2::PluginShareDeleteParams,
serialization: global("config"),
response: v2::PluginShareDeleteResponse,
},
AppsList => "app/list" {
params: v2::AppsListParams,
serialization: None,
@@ -4607,6 +4607,47 @@ pub struct PluginReadResponse {
pub plugin: PluginDetail,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareSaveParams {
pub plugin_path: AbsolutePathBuf,
#[ts(optional = nullable)]
pub remote_plugin_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareSaveResponse {
pub remote_plugin_id: String,
pub share_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListResponse {
pub data: Vec<PluginSummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareDeleteParams {
pub remote_plugin_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareDeleteResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -10603,6 +10644,101 @@ mod tests {
);
}
#[test]
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
let plugin_path = if cfg!(windows) {
r"C:\plugins\gmail"
} else {
"/plugins/gmail"
};
let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap();
let plugin_path_json = plugin_path.as_path().display().to_string();
assert_eq!(
serde_json::to_value(PluginShareSaveParams {
plugin_path: plugin_path.clone(),
remote_plugin_id: None,
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": null,
}),
);
assert_eq!(
serde_json::to_value(PluginShareSaveParams {
plugin_path,
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
})
.unwrap(),
json!({
"pluginPath": plugin_path_json,
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
}),
);
assert_eq!(
serde_json::to_value(PluginShareSaveResponse {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
share_url: String::new(),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"shareUrl": "",
}),
);
assert_eq!(
serde_json::from_value::<PluginShareListParams>(json!({})).unwrap(),
PluginShareListParams {},
);
assert_eq!(
serde_json::to_value(PluginShareDeleteParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
}),
);
}
#[test]
fn plugin_share_list_response_serializes_plugin_summaries() {
assert_eq!(
serde_json::to_value(PluginShareListResponse {
data: vec![PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
name: "gmail".to_string(),
source: PluginSource::Remote,
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: None,
}],
})
.unwrap(),
json!({
"data": [{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"interface": null,
}],
}),
);
}
#[test]
fn plugin_uninstall_params_serialization_omits_force_remote_sync() {
assert_eq!(
@@ -120,6 +120,12 @@ use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
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::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
@@ -1122,6 +1128,18 @@ impl CodexMessageProcessor {
self.plugin_read(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginShareSave { request_id, params } => {
self.plugin_share_save(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginShareList { request_id, params } => {
self.plugin_share_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginShareDelete { request_id, params } => {
self.plugin_share_delete(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::AppsList { request_id, params } => {
self.apps_list(to_connection_request_id(request_id), params)
.await;
@@ -256,22 +256,14 @@ impl CodexMessageProcessor {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
)));
return Err(invalid_request("remote plugin read is not enabled"));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
if plugin_name.is_empty()
|| !plugin_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
{
return Err(invalid_request(
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
));
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
@@ -303,6 +295,122 @@ impl CodexMessageProcessor {
Ok(PluginReadResponse { plugin })
}
pub(super) async fn plugin_share_save(
&self,
request_id: ConnectionRequestId,
params: PluginShareSaveParams,
) {
let result = self.plugin_share_save_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_save_response(
&self,
params: PluginShareSaveParams,
) -> Result<PluginShareSaveResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
} = 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"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
plugin_path.as_path(),
remote_plugin_id.as_deref(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareSaveResponse {
remote_plugin_id: result.remote_plugin_id,
share_url: result.share_url.unwrap_or_default(),
})
}
pub(super) async fn plugin_share_list(
&self,
request_id: ConnectionRequestId,
_params: PluginShareListParams,
) {
let result = self.plugin_share_list_response().await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_list_response(
&self,
) -> Result<PluginShareListResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let data = codex_core_plugins::remote::list_remote_plugin_shares(
&remote_plugin_service_config,
auth.as_ref(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
.into_iter()
.map(remote_plugin_summary_to_info)
.collect();
Ok(PluginShareListResponse { data })
}
pub(super) async fn plugin_share_delete(
&self,
request_id: ConnectionRequestId,
params: PluginShareDeleteParams,
) {
let result = self.plugin_share_delete_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_delete_response(
&self,
params: PluginShareDeleteParams,
) -> Result<PluginShareDeleteResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let PluginShareDeleteParams { remote_plugin_id } = 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(),
};
codex_core_plugins::remote::delete_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareDeleteResponse {})
}
async fn load_plugin_share_config_and_auth(
&self,
) -> Result<(Config, Option<CodexAuth>), JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request("plugin sharing is not enabled"));
}
let auth = self.auth_manager.auth().await;
Ok((config, auth))
}
pub(super) async fn plugin_install(
&self,
request_id: ConnectionRequestId,
@@ -401,14 +509,10 @@ impl CodexMessageProcessor {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
return Err(invalid_request("remote plugin install is not enabled"));
}
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
return Err(invalid_request(
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
));
return Err(invalid_request("invalid remote plugin id"));
}
let auth = self.auth_manager.auth().await;
@@ -434,9 +538,10 @@ impl CodexMessageProcessor {
"remote plugin {plugin_name} is not available for install"
)));
}
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle(
&plugin_name,
&remote_marketplace_name,
&actual_remote_marketplace_name,
&remote_detail.summary.name,
remote_detail.release_version.as_deref(),
remote_detail.bundle_download_url.as_deref(),
@@ -456,7 +561,7 @@ impl CodexMessageProcessor {
codex_core_plugins::remote::install_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&actual_remote_marketplace_name,
&plugin_name,
)
.await
@@ -571,13 +676,13 @@ impl CodexMessageProcessor {
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let PluginUninstallParams { plugin_id } = params;
if codex_core::plugins::PluginId::parse(&plugin_id).is_err()
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
{
return Err(invalid_request(
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `app_`, `asdk_app_`, or `connector_`",
"invalid plugin id: expected a local plugin id or remote plugin id",
));
}
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
return self.remote_plugin_uninstall_response(plugin_id).await;
}
let plugins_manager = self.thread_manager.plugins_manager();
@@ -669,9 +774,7 @@ impl CodexMessageProcessor {
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
return Err(invalid_request(
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
));
return Err(invalid_request("invalid remote plugin id"));
}
let auth = self.auth_manager.auth().await;
@@ -714,15 +817,6 @@ fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
}
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
!plugin_name.is_empty()
&& is_valid_remote_plugin_id(plugin_name)
&& (plugin_name.starts_with("plugins~")
|| plugin_name.starts_with("app_")
|| plugin_name.starts_with("asdk_app_")
|| plugin_name.starts_with("connector_"))
}
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: marketplace.name,
@@ -789,12 +883,6 @@ fn remote_plugin_catalog_error_to_jsonrpc(
data: None,
}
}
RemotePluginCatalogError::UnknownMarketplace { .. }
| RemotePluginCatalogError::MarketplaceMismatch { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -802,12 +890,22 @@ fn remote_plugin_catalog_error_to_jsonrpc(
data: None,
}
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
RemotePluginCatalogError::AuthToken(_)
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
| RemotePluginCatalogError::Decode { .. }
| RemotePluginCatalogError::UnexpectedPluginId { .. }
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
| RemotePluginCatalogError::Archive { .. }
| RemotePluginCatalogError::ArchiveJoin(_)
| RemotePluginCatalogError::MissingUploadEtag
| RemotePluginCatalogError::UnexpectedResponse(_)
| RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("{context}: {err}"),
@@ -33,6 +33,7 @@ mod plan_item;
mod plugin_install;
mod plugin_list;
mod plugin_read;
mod plugin_share;
mod plugin_uninstall;
mod rate_limits;
mod realtime_conversation;
@@ -177,7 +177,6 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable
.message
.contains("remote plugin install is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -405,11 +404,6 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid remote plugin id"));
assert!(
err.error
.message
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
);
Ok(())
}
@@ -1314,7 +1308,7 @@ async fn send_remote_plugin_install_request(
) -> Result<i64> {
mcp.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
plugin_name: remote_plugin_id.to_string(),
})
.await
@@ -139,7 +139,6 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
.message
.contains("remote plugin read is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -253,7 +252,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -413,11 +412,6 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid remote plugin id"));
assert!(
err.error
.message
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
);
Ok(())
}
@@ -0,0 +1,326 @@
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
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::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[tokio::test]
async fn plugin_share_save_uploads_local_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,
)?;
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"))
.and(header("x-ms-blob-type", "BlockBlob"))
.and(header("content-type", "application/gzip"))
.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(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
})))
.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 request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
})),
)
.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_list_returns_created_workspace_plugins() -> 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("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [installed_remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.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/list", Some(json!({})))
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareListResponse = to_response(response)?;
assert_eq!(
response,
PluginShareListResponse {
data: vec![PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
}],
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> 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("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(204))
.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/delete",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareDeleteResponse = to_response(response)?;
assert_eq!(response, PluginShareDeleteResponse {});
Ok(())
}
fn write_remote_plugin_config(codex_home: &Path, base_url: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
[features]
plugins = true
remote_plugin = true
"#
),
)
}
fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
json!({
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
"short_description": "A demo plugin",
"capabilities": ["Read", "Write"]
},
"skills": []
}
})
}
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
let mut plugin = remote_plugin_json(plugin_id);
let serde_json::Value::Object(fields) = &mut plugin else {
unreachable!("plugin json should be an object");
};
fields.insert("enabled".to_string(), json!(true));
fields.insert("disabled_skill_names".to_string(), json!([]));
plugin
}
fn empty_pagination_json() -> serde_json::Value {
json!({
"next_page_token": null
})
}
fn expected_plugin_interface() -> PluginInterface {
PluginInterface {
display_name: Some("Demo Plugin".to_string()),
short_description: Some("A demo plugin".to_string()),
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Read".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_url: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
}
fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf> {
let plugin_path = root.join(plugin_name);
write_file(
&plugin_path.join(".codex-plugin/plugin.json"),
&format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
write_file(
&plugin_path.join("skills/example/SKILL.md"),
"# Example\n\nA test skill.\n",
)?;
Ok(plugin_path)
}
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Err(std::io::Error::other(format!(
"file path `{}` should have a parent",
path.display()
)));
};
std::fs::create_dir_all(parent)?;
std::fs::write(path, contents)
}
@@ -26,6 +26,7 @@ use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear";
const WORKSPACE_REMOTE_PLUGIN_ID: &str = "plugins_69f27c3e67848191a45cbaa5f2adb39d";
#[tokio::test]
async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> {
@@ -323,6 +324,79 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<()> {
let codex_home = 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,
)?;
mount_remote_plugin_detail_with_name(
&server,
WORKSPACE_REMOTE_PLUGIN_ID,
"skill-improver",
"1.0.0",
"WORKSPACE",
)
.await;
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"id":"{WORKSPACE_REMOTE_PLUGIN_ID}","enabled":false}}"#
)))
.mount(&server)
.await;
let remote_plugin_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-workspace/skill-improver");
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?;
std::fs::write(
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
r#"{"name":"skill-improver","version":"1.0.0"}"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: WORKSPACE_REMOTE_PLUGIN_ID.to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginUninstallResponse = to_response(response)?;
assert_eq!(response, PluginUninstallResponse {});
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 1,
)
.await?;
assert!(!remote_plugin_cache_root.exists());
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -380,7 +454,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
}
#[tokio::test]
async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> {
async fn plugin_uninstall_rejects_invalid_plugin_id_before_remote_path() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
@@ -392,7 +466,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path()
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: "sample-plugin".to_string(),
plugin_id: "sample plugin".to_string(),
})
.await?;
@@ -407,7 +481,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path()
wait_for_remote_plugin_request_count(
&server,
"POST",
"/plugins/sample-plugin/uninstall",
"/plugins/sample plugin/uninstall",
/*expected_count*/ 0,
)
.await?;
@@ -519,11 +593,28 @@ async fn mount_remote_plugin_detail(
remote_plugin_id: &str,
release_version: &str,
scope: &str,
) {
mount_remote_plugin_detail_with_name(
server,
remote_plugin_id,
"linear",
release_version,
scope,
)
.await;
}
async fn mount_remote_plugin_detail_with_name(
server: &MockServer,
remote_plugin_id: &str,
plugin_name: &str,
release_version: &str,
scope: &str,
) {
let detail_body = format!(
r#"{{
"id": "{remote_plugin_id}",
"name": "linear",
"name": "{plugin_name}",
"scope": "{scope}",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
+41 -49
View File
@@ -16,6 +16,13 @@ use std::fs;
use std::path::PathBuf;
use std::time::Duration;
mod share;
pub use share::RemotePluginShareSaveResult;
pub use share::delete_remote_plugin_share;
pub use share::list_remote_plugin_shares;
pub use share::save_remote_plugin_share;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
@@ -111,18 +118,6 @@ pub enum RemotePluginCatalogError {
source: serde_json::Error,
},
#[error("remote marketplace `{marketplace_name}` is not supported")]
UnknownMarketplace { marketplace_name: String },
#[error(
"remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`"
)]
MarketplaceMismatch {
plugin_id: String,
expected_marketplace_name: String,
actual_marketplace_name: String,
},
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
@@ -137,6 +132,30 @@ pub enum RemotePluginCatalogError {
actual_enabled: bool,
},
#[error("invalid plugin path `{path}`: {reason}")]
InvalidPluginPath { path: PathBuf, reason: String },
#[error("failed to archive plugin at `{path}`: {source}")]
Archive {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to join plugin archive task: {0}")]
ArchiveJoin(#[source] tokio::task::JoinError),
#[error(
"plugin archive would be {bytes} bytes, exceeding the maximum upload size of {max_bytes} bytes"
)]
ArchiveTooLarge { bytes: usize, max_bytes: usize },
#[error("workspace plugin upload response did not include an etag")]
MissingUploadEtag,
#[error("{0}")]
UnexpectedResponse(String),
#[error("{0}")]
CacheRemove(String),
}
@@ -174,14 +193,6 @@ impl RemotePluginScope {
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
}
}
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -443,35 +454,19 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
async fn fetch_remote_plugin_detail_with_download_url_option(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
_marketplace_name: &str,
plugin_id: &str,
include_download_urls: bool,
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| {
RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
}
})?;
let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?;
let actual_marketplace_name = plugin.scope.marketplace_name();
if actual_marketplace_name != marketplace_name {
return Err(RemotePluginCatalogError::MarketplaceMismatch {
plugin_id: plugin_id.to_string(),
expected_marketplace_name: marketplace_name.to_string(),
actual_marketplace_name: actual_marketplace_name.to_string(),
});
}
let scope = plugin.scope;
let marketplace_name = scope.marketplace_name().to_string();
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
// marketplace name is not validated here. The backend detail response is the
// source of truth for the plugin's actual scope/marketplace.
build_remote_plugin_detail(
config,
auth,
scope,
marketplace_name.to_string(),
plugin_id,
plugin,
)
.await
build_remote_plugin_detail(config, auth, scope, marketplace_name, plugin_id, plugin).await
}
async fn build_remote_plugin_detail(
@@ -527,15 +522,12 @@ async fn build_remote_plugin_detail(
pub async fn install_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
_marketplace_name: &str,
plugin_id: &str,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
return Err(RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
});
}
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
// marketplace name is not validated before sending the install mutation.
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
+418
View File
@@ -0,0 +1,418 @@
use super::*;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use flate2::Compression;
use flate2::write::GzEncoder;
use reqwest::RequestBuilder;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io;
use std::io::Write;
use std::path::Path;
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareSaveResult {
pub remote_plugin_id: String,
pub share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginUploadUrlRequest<'a> {
filename: &'a str,
mime_type: &'a str,
size_bytes: usize,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspacePluginUploadUrlResponse {
file_id: String,
upload_url: String,
etag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginCreateRequest {
file_id: String,
etag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspacePluginCreateResponse {
plugin_id: String,
share_url: Option<String>,
}
pub async fn save_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_path: &Path,
remote_plugin_id: Option<&str>,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugin_path = plugin_path.to_path_buf();
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
let filename = archive_filename(&plugin_path)?;
let archive_bytes = archive_plugin_for_upload(&plugin_path)?;
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
})
.await
.map_err(RemotePluginCatalogError::ArchiveJoin)??;
let upload = create_workspace_plugin_upload(
config,
auth,
&filename,
archive_bytes.len(),
remote_plugin_id,
)
.await?;
let etag = upload
.etag
.ok_or(RemotePluginCatalogError::MissingUploadEtag)?;
put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?;
let response = finalize_workspace_plugin_upload(
config,
auth,
remote_plugin_id,
RemoteWorkspacePluginCreateRequest {
file_id: upload.file_id,
etag,
},
)
.await?;
if response.plugin_id.is_empty() {
return Err(RemotePluginCatalogError::UnexpectedResponse(
"workspace plugin create response did not include a plugin id".to_string(),
));
}
Ok(RemotePluginShareSaveResult {
remote_plugin_id: response.plugin_id,
share_url: response.share_url,
})
}
pub async fn list_remote_plugin_shares(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
if created_plugins.is_empty() {
return Ok(Vec::new());
}
let installed_by_id =
fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace)
.await?
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect::<BTreeMap<_, _>>();
Ok(created_plugins
.into_iter()
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
.collect())
}
pub async fn delete_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}");
let client = build_reqwest_client();
let request = authenticated_request(client.delete(&url), auth)?;
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await
}
async fn fetch_created_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
let mut plugins = Vec::new();
let mut page_token = None;
loop {
let response =
get_created_workspace_plugins_page(config, auth, page_token.as_deref()).await?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
};
page_token = Some(next_page_token);
}
Ok(plugins)
}
async fn get_created_workspace_plugins_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/workspace/created");
let client = build_reqwest_client();
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]);
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
send_and_decode(request, &url).await
}
async fn create_workspace_plugin_upload(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
filename: &str,
size_bytes: usize,
remote_plugin_id: Option<&str>,
) -> Result<RemoteWorkspacePluginUploadUrlResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/workspace/upload-url");
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?.json(
&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
size_bytes,
plugin_id: remote_plugin_id,
},
);
send_and_decode(request, &url).await
}
async fn put_workspace_plugin_upload(
upload_url: &str,
archive_bytes: Vec<u8>,
) -> Result<(), RemotePluginCatalogError> {
let client = build_reqwest_client();
let request = client
.put(upload_url)
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.header("x-ms-blob-type", "BlockBlob")
.header("Content-Type", "application/gzip")
.body(archive_bytes);
let response = request
.send()
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: "workspace plugin upload URL".to_string(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status) {
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: "workspace plugin upload URL".to_string(),
status,
body,
});
}
Ok(())
}
async fn finalize_workspace_plugin_upload(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
remote_plugin_id: Option<&str>,
body: RemoteWorkspacePluginCreateRequest,
) -> Result<RemoteWorkspacePluginCreateResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = if let Some(remote_plugin_id) = remote_plugin_id {
format!("{base_url}/public/plugins/workspace/{remote_plugin_id}")
} else {
format!("{base_url}/public/plugins/workspace")
};
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?.json(&body);
send_and_decode(request, &url).await
}
fn archive_filename(plugin_path: &Path) -> Result<String, RemotePluginCatalogError> {
let plugin_name = plugin_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "plugin path must end in a valid UTF-8 directory name".to_string(),
})?;
Ok(format!("{plugin_name}.tar.gz"))
}
fn archive_plugin_for_upload(plugin_path: &Path) -> Result<Vec<u8>, RemotePluginCatalogError> {
archive_plugin_for_upload_with_limit(plugin_path, REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES)
}
fn archive_plugin_for_upload_with_limit(
plugin_path: &Path,
max_bytes: usize,
) -> Result<Vec<u8>, RemotePluginCatalogError> {
if !plugin_path.is_dir() {
return Err(RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "expected a plugin directory".to_string(),
});
}
if !plugin_path.join(".codex-plugin/plugin.json").is_file() {
return Err(RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "missing .codex-plugin/plugin.json".to_string(),
});
}
let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default());
let mut archive = tar::Builder::new(encoder);
append_plugin_tree(&mut archive, plugin_path, plugin_path)
.map_err(|source| archive_error(plugin_path, source))?;
let encoder = archive
.into_inner()
.map_err(|source| archive_error(plugin_path, source))?;
encoder
.finish()
.map(SizeLimitedBuffer::into_inner)
.map_err(|source| archive_error(plugin_path, source))
}
fn append_plugin_tree<W: Write>(
archive: &mut tar::Builder<W>,
plugin_root: &Path,
current: &Path,
) -> io::Result<()> {
let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, io::Error>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
let file_type = entry.file_type()?;
let relative_path = path.strip_prefix(plugin_root).map_err(|err| {
io::Error::other(format!(
"failed to compute plugin archive path for `{}`: {err}",
path.display()
))
})?;
if file_type.is_dir() {
archive.append_dir(relative_path, &path)?;
append_plugin_tree(archive, plugin_root, &path)?;
} else if file_type.is_file() {
archive.append_path_with_name(&path, relative_path)?;
} else {
return Err(io::Error::other(format!(
"unsupported plugin archive entry type: {}",
path.display()
)));
}
}
Ok(())
}
fn archive_error(plugin_path: &Path, source: io::Error) -> RemotePluginCatalogError {
if let Some(limit) = source
.get_ref()
.and_then(|err| err.downcast_ref::<ArchiveSizeLimitExceeded>())
{
return RemotePluginCatalogError::ArchiveTooLarge {
bytes: limit.bytes,
max_bytes: limit.max_bytes,
};
}
RemotePluginCatalogError::Archive {
path: plugin_path.to_path_buf(),
source,
}
}
struct SizeLimitedBuffer {
bytes: Vec<u8>,
max_bytes: usize,
}
impl SizeLimitedBuffer {
fn new(max_bytes: usize) -> Self {
Self {
bytes: Vec::new(),
max_bytes,
}
}
fn into_inner(self) -> Vec<u8> {
self.bytes
}
}
impl Write for SizeLimitedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| {
io::Error::other(ArchiveSizeLimitExceeded {
bytes: usize::MAX,
max_bytes: self.max_bytes,
})
})?;
if next_len > self.max_bytes {
return Err(io::Error::other(ArchiveSizeLimitExceeded {
bytes: next_len,
max_bytes: self.max_bytes,
}));
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Debug)]
struct ArchiveSizeLimitExceeded {
bytes: usize,
max_bytes: usize,
}
impl fmt::Display for ArchiveSizeLimitExceeded {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"archive would be {} bytes, exceeding maximum size of {} bytes",
self.bytes, self.max_bytes
)
}
}
impl std::error::Error for ArchiveSizeLimitExceeded {}
async fn send_and_expect_status(
request: RequestBuilder,
url_for_error: &str,
expected_statuses: &[StatusCode],
) -> Result<(), RemotePluginCatalogError> {
let response = request
.send()
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: url_for_error.to_string(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !expected_statuses.contains(&status) {
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: url_for_error.to_string(),
status,
body,
});
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,417 @@
use super::*;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
use wiremock::matchers::query_param_is_missing;
fn test_config(server: &MockServer) -> RemotePluginServiceConfig {
RemotePluginServiceConfig {
chatgpt_base_url: format!("{}/backend-api", server.uri()),
}
}
fn test_auth() -> CodexAuth {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
}
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {
let plugin_path = root.join(plugin_name);
write_file(
&plugin_path.join(".codex-plugin/plugin.json"),
&format!(r#"{{"name":"{plugin_name}"}}"#),
);
write_file(
&plugin_path.join("skills/example/SKILL.md"),
"# Example\n\nA test skill.\n",
);
plugin_path
}
fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let decoder = flate2::read::GzDecoder::new(archive_bytes);
let mut archive = tar::Archive::new(decoder);
archive
.entries()
.unwrap()
.filter_map(|entry| {
let mut entry = entry.unwrap();
if !entry.header().entry_type().is_file() {
return None;
}
let path = entry.path().unwrap().to_string_lossy().into_owned();
let mut contents = Vec::new();
entry.read_to_end(&mut contents).unwrap();
Some((path, contents))
})
.collect()
}
fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
json!({
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
"short_description": "A demo plugin",
"capabilities": ["Read", "Write"]
},
"skills": []
}
})
}
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
let mut plugin = remote_plugin_json(plugin_id);
let serde_json::Value::Object(fields) = &mut plugin else {
unreachable!("plugin json should be an object");
};
fields.insert("enabled".to_string(), json!(true));
fields.insert("disabled_skill_names".to_string(), json!([]));
plugin
}
fn empty_pagination_json() -> serde_json::Value {
json!({
"next_page_token": null
})
}
fn expected_plugin_interface() -> PluginInterface {
PluginInterface {
display_name: Some("Demo Plugin".to_string()),
short_description: Some("A demo plugin".to_string()),
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Read".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_url: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
}
#[tokio::test]
async fn save_remote_plugin_share_creates_workspace_plugin() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"filename": "demo-plugin.tar.gz",
"mime_type": "application/gzip",
"size_bytes": archive_size,
})))
.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"))
.and(header("x-ms-blob-type", "BlockBlob"))
.and(header("content-type", "application/gzip"))
.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(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
})))
.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 result = save_remote_plugin_share(
&config,
Some(&auth),
&plugin_path,
/*remote_plugin_id*/ None,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
}
);
let requests = server.received_requests().await.unwrap_or_default();
let upload_request = requests
.iter()
.find(|request| request.method == "PUT" && request.url.path() == "/upload/file_123")
.unwrap();
let archive_files = archive_file_entries(&upload_request.body);
assert_eq!(
archive_files
.get(".codex-plugin/plugin.json")
.map(Vec::as_slice),
Some(br#"{"name":"demo-plugin"}"#.as_slice())
);
assert_eq!(
archive_files
.get("skills/example/SKILL.md")
.map(Vec::as_slice),
Some(b"# Example\n\nA test skill.\n".as_slice())
);
}
#[test]
fn archive_plugin_for_upload_rejects_archives_over_limit() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
write_file(
&plugin_path.join("large.txt"),
&"0123456789abcdef".repeat(1024),
);
let err = archive_plugin_for_upload_with_limit(&plugin_path, /*max_bytes*/ 16)
.expect_err("oversized plugin archive should fail");
assert!(matches!(
err,
RemotePluginCatalogError::ArchiveTooLarge { .. }
));
}
#[test]
fn archive_plugin_for_upload_places_manifest_at_archive_root() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap();
let archive_files = archive_file_entries(&archive_bytes);
assert_eq!(
archive_files.keys().cloned().collect::<Vec<_>>(),
vec![
".codex-plugin/plugin.json".to_string(),
"skills/example/SKILL.md".to_string()
]
);
assert_eq!(
archive_files
.get(".codex-plugin/plugin.json")
.map(Vec::as_slice),
Some(br#"{"name":"demo-plugin"}"#.as_slice())
);
assert_eq!(
archive_files
.get("skills/example/SKILL.md")
.map(Vec::as_slice),
Some(b"# Example\n\nA test skill.\n".as_slice())
);
}
#[tokio::test]
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(body_json(json!({
"filename": "demo-plugin.tar.gz",
"mime_type": "application/gzip",
"size_bytes": archive_size,
"plugin_id": "plugins_123",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_456",
"upload_url": format!("{}/upload/file_456", server.uri()),
"etag": "\"upload_etag_456\"",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_456"))
.respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_456\""))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(body_json(json!({
"file_id": "file_456",
"etag": "\"upload_etag_456\"",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugin_id": "plugins_123",
})))
.expect(1)
.mount(&server)
.await;
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: None,
}
);
}
#[tokio::test]
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(query_param(
"limit",
REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),
))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": {
"next_page_token": "page-2"
},
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(query_param(
"limit",
REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),
))
.and(query_param("pageToken", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_456")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [installed_remote_plugin_json("plugins_456")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
let result = list_remote_plugin_shares(&config, Some(&auth))
.await
.unwrap();
assert_eq!(
result,
vec![
RemotePluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
},
RemotePluginSummary {
id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
}
]
);
}
#[tokio::test]
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
.await
.unwrap();
}