mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Add plugin share checkout (#22435)
Adds plugin/share/checkout to turn a shared remote plugin into a local working copy under ~/plugins/<name>. Registers the copy in the managed personal marketplace and records the remote-to-local mapping for later share/save flows. --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
392e94e9ea
commit
2a67c46de4
@@ -2015,6 +2015,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareCheckoutParams": {
|
||||
"properties": {
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remotePluginId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareDeleteParams": {
|
||||
"properties": {
|
||||
"remotePluginId": {
|
||||
@@ -5075,6 +5086,30 @@
|
||||
"title": "Plugin/share/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/share/checkout"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginShareCheckoutParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+76
@@ -877,6 +877,30 @@
|
||||
"title": "Plugin/share/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/share/checkout"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginShareCheckoutParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -12268,6 +12292,58 @@
|
||||
"title": "PluginReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareCheckoutParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareCheckoutResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginPath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteVersion": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginId",
|
||||
"pluginName",
|
||||
"pluginPath",
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareContext": {
|
||||
"properties": {
|
||||
"creatorAccountUserId": {
|
||||
|
||||
+76
@@ -1617,6 +1617,30 @@
|
||||
"title": "Plugin/share/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/share/checkout"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginShareCheckoutParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/share/checkoutRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8817,6 +8841,58 @@
|
||||
"title": "PluginReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareCheckoutParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareCheckoutResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginPath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteVersion": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginId",
|
||||
"pluginName",
|
||||
"pluginPath",
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareContext": {
|
||||
"properties": {
|
||||
"creatorAccountUserId": {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutParams",
|
||||
"type": "object"
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$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": {
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginPath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteVersion": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"marketplacePath",
|
||||
"pluginId",
|
||||
"pluginName",
|
||||
"pluginPath",
|
||||
"remotePluginId"
|
||||
],
|
||||
"title": "PluginShareCheckoutResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+5
@@ -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 PluginShareCheckoutParams = { remotePluginId: string, };
|
||||
+6
@@ -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 PluginShareCheckoutResponse = { remotePluginId: string, pluginId: string, pluginName: string, pluginPath: AbsolutePathBuf, marketplaceName: string, marketplacePath: AbsolutePathBuf, remoteVersion: string | null, };
|
||||
@@ -277,6 +277,8 @@ export type { PluginListResponse } from "./PluginListResponse";
|
||||
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
export type { PluginReadParams } from "./PluginReadParams";
|
||||
export type { PluginReadResponse } from "./PluginReadResponse";
|
||||
export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams";
|
||||
export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse";
|
||||
export type { PluginShareContext } from "./PluginShareContext";
|
||||
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
|
||||
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
|
||||
|
||||
@@ -649,6 +649,11 @@ client_request_definitions! {
|
||||
serialization: global("config"),
|
||||
response: v2::PluginShareListResponse,
|
||||
},
|
||||
PluginShareCheckout => "plugin/share/checkout" {
|
||||
params: v2::PluginShareCheckoutParams,
|
||||
serialization: global("config"),
|
||||
response: v2::PluginShareCheckoutResponse,
|
||||
},
|
||||
PluginShareDelete => "plugin/share/delete" {
|
||||
params: v2::PluginShareDeleteParams,
|
||||
serialization: global("config"),
|
||||
|
||||
@@ -242,6 +242,26 @@ pub struct PluginShareListResponse {
|
||||
pub data: Vec<PluginShareListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareCheckoutParams {
|
||||
pub remote_plugin_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginShareCheckoutResponse {
|
||||
pub remote_plugin_id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub plugin_path: AbsolutePathBuf,
|
||||
pub marketplace_name: String,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub remote_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -2987,6 +2987,52 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
|
||||
PluginShareListParams {},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginShareCheckoutParams {
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
}),
|
||||
);
|
||||
|
||||
let plugin_path = if cfg!(windows) {
|
||||
r"C:\Users\me\plugins\gmail"
|
||||
} else {
|
||||
"/Users/me/plugins/gmail"
|
||||
};
|
||||
let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap();
|
||||
let plugin_path_json = plugin_path.as_path().display().to_string();
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
r"C:\Users\me\.agents\plugins\marketplace.json"
|
||||
} else {
|
||||
"/Users/me/.agents/plugins/marketplace.json"
|
||||
};
|
||||
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
|
||||
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginShareCheckoutResponse {
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
plugin_id: "gmail@codex-curated".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
plugin_path,
|
||||
marketplace_name: "codex-curated".to_string(),
|
||||
marketplace_path,
|
||||
remote_version: Some("1.2.3".to_string()),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"pluginId": "gmail@codex-curated",
|
||||
"pluginName": "gmail",
|
||||
"pluginPath": plugin_path_json,
|
||||
"marketplaceName": "codex-curated",
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"remoteVersion": "1.2.3",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginShareDeleteParams {
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
|
||||
@@ -1099,6 +1099,9 @@ impl MessageProcessor {
|
||||
ClientRequest::PluginShareList { params, .. } => {
|
||||
self.plugin_processor.plugin_share_list(params).await
|
||||
}
|
||||
ClientRequest::PluginShareCheckout { params, .. } => {
|
||||
self.plugin_processor.plugin_share_checkout(params).await
|
||||
}
|
||||
ClientRequest::PluginShareDelete { params, .. } => {
|
||||
self.plugin_processor.plugin_share_delete(params).await
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ 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::PluginShareCheckoutParams;
|
||||
use codex_app_server_protocol::PluginShareCheckoutResponse;
|
||||
use codex_app_server_protocol::PluginShareContext;
|
||||
use codex_app_server_protocol::PluginShareDeleteParams;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
|
||||
@@ -313,6 +313,15 @@ impl PluginRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn plugin_share_checkout(
|
||||
&self,
|
||||
params: PluginShareCheckoutParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.plugin_share_checkout_response(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn plugin_share_delete(
|
||||
&self,
|
||||
params: PluginShareDeleteParams,
|
||||
@@ -973,6 +982,42 @@ impl PluginRequestProcessor {
|
||||
Ok(PluginShareListResponse { data })
|
||||
}
|
||||
|
||||
async fn plugin_share_checkout_response(
|
||||
&self,
|
||||
params: PluginShareCheckoutParams,
|
||||
) -> Result<PluginShareCheckoutResponse, JSONRPCErrorError> {
|
||||
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
|
||||
if !config.features.enabled(Feature::PluginSharing) {
|
||||
return Err(invalid_request("plugin sharing is disabled"));
|
||||
}
|
||||
let PluginShareCheckoutParams { 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(),
|
||||
};
|
||||
let result = codex_core_plugins::remote::checkout_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?;
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(PluginShareCheckoutResponse {
|
||||
remote_plugin_id: result.remote_plugin_id,
|
||||
plugin_id: result.plugin_id,
|
||||
plugin_name: result.plugin_name,
|
||||
plugin_path: result.plugin_path,
|
||||
marketplace_name: result.marketplace_name,
|
||||
marketplace_path: result.marketplace_path,
|
||||
remote_version: result.remote_version,
|
||||
})
|
||||
}
|
||||
|
||||
async fn plugin_share_delete_response(
|
||||
&self,
|
||||
params: PluginShareDeleteParams,
|
||||
@@ -1694,6 +1739,7 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
invalid_request(message)
|
||||
}
|
||||
RemotePluginCatalogError::InvalidPluginPath { .. }
|
||||
| RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. }
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. }
|
||||
| RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message),
|
||||
RemotePluginCatalogError::AuthToken(_)
|
||||
|
||||
@@ -12,6 +12,9 @@ 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::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginShareCheckoutResponse;
|
||||
use codex_app_server_protocol::PluginShareContext;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareDiscoverability;
|
||||
@@ -27,6 +30,8 @@ use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
@@ -41,6 +46,8 @@ use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
||||
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
@@ -587,6 +594,335 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let 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,
|
||||
)?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
"demo-plugin",
|
||||
remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,
|
||||
)
|
||||
.await;
|
||||
mount_remote_plugin_detail_with_bundle(
|
||||
&server,
|
||||
"plugins_123",
|
||||
"demo-plugin",
|
||||
&bundle_url,
|
||||
"WORKSPACE",
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;
|
||||
|
||||
let home_env = home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home_env.as_str())),
|
||||
("USERPROFILE", Some(home_env.as_str())),
|
||||
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/checkout",
|
||||
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: PluginShareCheckoutResponse = to_response(response)?;
|
||||
|
||||
let plugin_path = AbsolutePathBuf::try_from(home.path().join("plugins/demo-plugin"))?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(home.path().join(".agents/plugins/marketplace.json"))?;
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareCheckoutResponse {
|
||||
remote_plugin_id: "plugins_123".to_string(),
|
||||
plugin_id: "demo-plugin@codex-curated".to_string(),
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
plugin_path: plugin_path.clone(),
|
||||
marketplace_name: "codex-curated".to_string(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
remote_version: Some("1.2.3".to_string()),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
plugin_path
|
||||
.as_path()
|
||||
.join(".codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
|
||||
let marketplace: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(marketplace_path.as_path())?)?;
|
||||
assert_eq!(
|
||||
marketplace,
|
||||
json!({
|
||||
"name": "codex-curated",
|
||||
"interface": {
|
||||
"displayName": "Personal",
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin",
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_USE",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
let mapping: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
|
||||
codex_home
|
||||
.path()
|
||||
.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
)?)?;
|
||||
assert_eq!(
|
||||
mapping,
|
||||
json!({
|
||||
"localPluginPathsByRemotePluginId": {
|
||||
"plugins_123": plugin_path.clone(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![
|
||||
codex_app_server_protocol::PluginListMarketplaceKind::Local,
|
||||
]),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
assert_eq!(response.marketplaces.len(), 1);
|
||||
assert_eq!(response.marketplaces[0].name, "codex-curated");
|
||||
assert_eq!(response.marketplaces[0].plugins[0].name, "demo-plugin");
|
||||
assert_eq!(
|
||||
response.marketplaces[0].plugins[0]
|
||||
.share_context
|
||||
.as_ref()
|
||||
.map(|context| context.remote_plugin_id.as_str()),
|
||||
Some("plugins_123")
|
||||
);
|
||||
|
||||
std::fs::write(plugin_path.as_path().join("local-edit.txt"), "keep")?;
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/checkout",
|
||||
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: PluginShareCheckoutResponse = to_response(response)?;
|
||||
assert_eq!(response.plugin_path, plugin_path);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(plugin_path.as_path().join("local-edit.txt"))?,
|
||||
"keep"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let 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,
|
||||
)?;
|
||||
|
||||
let bundle_url = format!("{}/bundles/global-plugin.tar.gz", server.uri());
|
||||
mount_remote_plugin_detail_with_bundle(
|
||||
&server,
|
||||
"plugins_global",
|
||||
"global-plugin",
|
||||
&bundle_url,
|
||||
"GLOBAL",
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server, "GLOBAL").await;
|
||||
|
||||
let home_env = home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home_env.as_str())),
|
||||
("USERPROFILE", Some(home_env.as_str())),
|
||||
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/checkout",
|
||||
Some(json!({
|
||||
"remotePluginId": "plugins_global",
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(
|
||||
error
|
||||
.error
|
||||
.message
|
||||
.contains("not available for plugin/share/checkout")
|
||||
);
|
||||
assert!(!home.path().join("plugins/global-plugin").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let 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,
|
||||
)?;
|
||||
|
||||
let marketplace_path = home.path().join(".agents/plugins/marketplace.json");
|
||||
std::fs::create_dir_all(
|
||||
marketplace_path
|
||||
.parent()
|
||||
.expect("marketplace path has parent"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
&marketplace_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./other/demo-plugin",
|
||||
},
|
||||
},
|
||||
],
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
"demo-plugin",
|
||||
remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,
|
||||
)
|
||||
.await;
|
||||
mount_remote_plugin_detail_with_bundle(
|
||||
&server,
|
||||
"plugins_123",
|
||||
"demo-plugin",
|
||||
&bundle_url,
|
||||
"WORKSPACE",
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;
|
||||
|
||||
let home_env = home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home_env.as_str())),
|
||||
("USERPROFILE", Some(home_env.as_str())),
|
||||
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/checkout",
|
||||
Some(json!({
|
||||
"remotePluginId": "plugins_123",
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(
|
||||
error
|
||||
.error
|
||||
.message
|
||||
.contains("marketplace already contains plugin `demo-plugin`")
|
||||
);
|
||||
assert!(!home.path().join("plugins/demo-plugin").exists());
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(".tmp/plugin-share-local-paths-v1.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -823,6 +1159,85 @@ remote_plugin = true
|
||||
)
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_bundle(
|
||||
server: &MockServer,
|
||||
plugin_name: &str,
|
||||
body: Vec<u8>,
|
||||
) -> String {
|
||||
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
|
||||
Mock::given(method("GET"))
|
||||
.and(path(bundle_path.clone()))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/gzip")
|
||||
.set_body_bytes(body),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
format!("{}{}", server.uri(), bundle_path)
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_with_bundle(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
plugin_name: &str,
|
||||
bundle_url: &str,
|
||||
scope: &str,
|
||||
) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": remote_plugin_id,
|
||||
"name": plugin_name,
|
||||
"scope": scope,
|
||||
"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",
|
||||
},
|
||||
],
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"version": "1.2.3",
|
||||
"bundle_download_url": bundle_url,
|
||||
"display_name": "Demo Plugin",
|
||||
"description": "Demo plugin description",
|
||||
"interface": {
|
||||
"short_description": "A demo plugin",
|
||||
"capabilities": ["Read", "Write"],
|
||||
},
|
||||
"skills": [],
|
||||
},
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_empty_remote_installed_plugins(server: &MockServer, scope: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"next_page_token": null,
|
||||
},
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"id": plugin_id,
|
||||
@@ -935,6 +1350,32 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
|
||||
Ok(plugin_path)
|
||||
}
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
||||
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
||||
let skill = "# Example\n\nA test skill.\n";
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
for (path, contents, mode) in [
|
||||
(
|
||||
".codex-plugin/plugin.json",
|
||||
manifest.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
(
|
||||
"skills/example/SKILL.md",
|
||||
skill.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, path, contents)?;
|
||||
}
|
||||
Ok(tar.into_inner()?.finish()?)
|
||||
}
|
||||
|
||||
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
|
||||
@@ -7,7 +7,6 @@ use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
@@ -225,6 +224,16 @@ pub fn list_marketplaces(
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn home_dir() -> Option<PathBuf> {
|
||||
["HOME", "USERPROFILE"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.find(|path| path.is_absolute())
|
||||
.or_else(dirs::home_dir)
|
||||
}
|
||||
|
||||
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
|
||||
let Some(path) = find_marketplace_manifest_path(root) else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
|
||||
@@ -39,6 +39,7 @@ pub use share::RemotePluginShareTarget;
|
||||
pub use share::RemotePluginShareTargetRole;
|
||||
pub use share::RemotePluginShareUpdateDiscoverability;
|
||||
pub use share::RemotePluginShareUpdateTargetsResult;
|
||||
pub use share::checkout_remote_plugin_share;
|
||||
pub use share::delete_remote_plugin_share;
|
||||
pub use share::list_remote_plugin_shares;
|
||||
pub use share::load_plugin_share_remote_ids_by_local_path;
|
||||
@@ -233,6 +234,9 @@ pub enum RemotePluginCatalogError {
|
||||
#[error("invalid plugin path `{path}`: {reason}")]
|
||||
InvalidPluginPath { path: PathBuf, reason: String },
|
||||
|
||||
#[error("remote plugin `{remote_plugin_id}` is not available for plugin/share/checkout")]
|
||||
PluginShareCheckoutNotAvailable { remote_plugin_id: String },
|
||||
|
||||
#[error("failed to archive plugin at `{path}`: {source}")]
|
||||
Archive {
|
||||
path: PathBuf,
|
||||
|
||||
@@ -16,10 +16,13 @@ use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
mod checkout;
|
||||
mod local_paths;
|
||||
|
||||
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
pub use checkout::checkout_remote_plugin_share;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginShareSaveResult {
|
||||
pub remote_plugin_id: String,
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
|
||||
use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME;
|
||||
use super::super::RemotePluginCatalogError;
|
||||
use super::super::RemotePluginServiceConfig;
|
||||
use super::local_paths;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
const PERSONAL_MARKETPLACE_NAME: &str = "codex-curated";
|
||||
const PERSONAL_MARKETPLACE_DISPLAY_NAME: &str = "Personal";
|
||||
const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginShareCheckoutResult {
|
||||
pub remote_plugin_id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub plugin_path: AbsolutePathBuf,
|
||||
pub marketplace_name: String,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub remote_version: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn checkout_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> Result<RemotePluginShareCheckoutResult, RemotePluginCatalogError> {
|
||||
let detail = super::super::fetch_remote_plugin_detail_with_download_urls(
|
||||
config,
|
||||
auth,
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
|
||||
remote_plugin_id,
|
||||
)
|
||||
.await?;
|
||||
let plugin_name = detail.summary.name.clone();
|
||||
let remote_version = detail.release_version.clone();
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(|reason| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"remote plugin `{remote_plugin_id}` returned invalid plugin name: {reason}"
|
||||
))
|
||||
})?;
|
||||
if !is_checkout_supported_share_marketplace(&detail.marketplace_name)
|
||||
|| detail.summary.share_context.is_none()
|
||||
{
|
||||
return Err(RemotePluginCatalogError::PluginShareCheckoutNotAvailable {
|
||||
remote_plugin_id: remote_plugin_id.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let home = crate::marketplace::home_dir().ok_or_else(|| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(
|
||||
"could not determine home directory for personal plugin marketplace".to_string(),
|
||||
)
|
||||
})?;
|
||||
let home = AbsolutePathBuf::try_from(home).map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to resolve home directory for personal plugin marketplace: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let local_paths = load_share_local_paths_for_checkout(codex_home)?;
|
||||
let (local_plugin_path, already_checked_out) =
|
||||
editable_plugin_path_for_checkout(&home, &plugin_name, remote_plugin_id, &local_paths)?;
|
||||
|
||||
let mut created_checkout_path = false;
|
||||
if !already_checked_out {
|
||||
let bundle = crate::remote_bundle::validate_remote_plugin_bundle(
|
||||
remote_plugin_id,
|
||||
&detail.marketplace_name,
|
||||
&plugin_name,
|
||||
detail.release_version.as_deref(),
|
||||
detail.bundle_download_url.as_deref(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to prepare remote plugin bundle checkout: {err}"
|
||||
))
|
||||
})?;
|
||||
crate::remote_bundle::download_and_extract_remote_plugin_bundle_to_path(
|
||||
bundle,
|
||||
local_plugin_path.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to check out remote plugin bundle: {err}"
|
||||
))
|
||||
})?;
|
||||
created_checkout_path = true;
|
||||
}
|
||||
|
||||
let marketplace = match update_personal_marketplace(
|
||||
&home,
|
||||
&plugin_name,
|
||||
&local_plugin_path,
|
||||
detail.summary.install_policy,
|
||||
detail.summary.auth_policy,
|
||||
detail
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.category.clone()),
|
||||
) {
|
||||
Ok(marketplace) => marketplace,
|
||||
Err(err) => {
|
||||
return Err(clean_up_created_checkout_path(
|
||||
created_checkout_path,
|
||||
&local_plugin_path,
|
||||
err,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = local_paths::record_plugin_share_local_path(
|
||||
codex_home,
|
||||
remote_plugin_id,
|
||||
local_plugin_path.clone(),
|
||||
) {
|
||||
let err = RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to record plugin share local path mapping: {err}"
|
||||
));
|
||||
return Err(clean_up_created_checkout_path(
|
||||
created_checkout_path,
|
||||
&local_plugin_path,
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
let plugin_id = PluginId::new(plugin_name.clone(), marketplace.name.clone())
|
||||
.map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to build checked out plugin id: {err}"
|
||||
))
|
||||
})?
|
||||
.as_key();
|
||||
|
||||
Ok(RemotePluginShareCheckoutResult {
|
||||
remote_plugin_id: remote_plugin_id.to_string(),
|
||||
plugin_id,
|
||||
plugin_name,
|
||||
plugin_path: local_plugin_path,
|
||||
marketplace_name: marketplace.name,
|
||||
marketplace_path: marketplace.path,
|
||||
remote_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_checkout_supported_share_marketplace(marketplace_name: &str) -> bool {
|
||||
matches!(
|
||||
marketplace_name,
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME
|
||||
| REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME
|
||||
)
|
||||
}
|
||||
|
||||
fn load_share_local_paths_for_checkout(
|
||||
codex_home: &Path,
|
||||
) -> Result<BTreeMap<String, AbsolutePathBuf>, RemotePluginCatalogError> {
|
||||
match local_paths::load_plugin_share_local_paths(codex_home) {
|
||||
Ok(paths) => Ok(paths),
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()),
|
||||
Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to load plugin share local path mapping: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn editable_plugin_path_for_checkout(
|
||||
home: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
remote_plugin_id: &str,
|
||||
local_paths: &BTreeMap<String, AbsolutePathBuf>,
|
||||
) -> Result<(AbsolutePathBuf, bool), RemotePluginCatalogError> {
|
||||
if let Some(existing_path) = local_paths.get(remote_plugin_id)
|
||||
&& existing_path.as_path().exists()
|
||||
{
|
||||
ensure_path_can_be_listed_in_personal_marketplace(home, existing_path)?;
|
||||
return Ok((existing_path.clone(), true));
|
||||
}
|
||||
|
||||
let local_plugin_path = local_paths
|
||||
.get(remote_plugin_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| home.join("plugins").join(plugin_name));
|
||||
ensure_path_can_be_listed_in_personal_marketplace(home, &local_plugin_path)?;
|
||||
|
||||
if local_plugin_path.as_path().exists() {
|
||||
return Err(RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: local_plugin_path.to_path_buf(),
|
||||
reason: format!(
|
||||
"cannot check out remote plugin `{remote_plugin_id}` because the local plugin path already exists"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok((local_plugin_path, false))
|
||||
}
|
||||
|
||||
fn clean_up_created_checkout_path(
|
||||
created_checkout_path: bool,
|
||||
local_plugin_path: &AbsolutePathBuf,
|
||||
original_err: RemotePluginCatalogError,
|
||||
) -> RemotePluginCatalogError {
|
||||
if !created_checkout_path {
|
||||
return original_err;
|
||||
}
|
||||
|
||||
match remove_created_checkout_path(local_plugin_path) {
|
||||
Ok(()) => original_err,
|
||||
Err(cleanup_err) => RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"{original_err}; additionally failed to clean up checked out plugin path `{}`: {cleanup_err}",
|
||||
local_plugin_path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_created_checkout_path(local_plugin_path: &AbsolutePathBuf) -> io::Result<()> {
|
||||
if local_plugin_path.as_path().is_dir() {
|
||||
fs::remove_dir_all(local_plugin_path.as_path())
|
||||
} else {
|
||||
fs::remove_file(local_plugin_path.as_path())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_path_can_be_listed_in_personal_marketplace(
|
||||
home: &AbsolutePathBuf,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
personal_marketplace_relative_plugin_path(home, path).map(|_| ())
|
||||
}
|
||||
|
||||
struct PersonalMarketplaceUpdate {
|
||||
name: String,
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
fn update_personal_marketplace(
|
||||
home: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
local_plugin_path: &AbsolutePathBuf,
|
||||
install_policy: PluginInstallPolicy,
|
||||
auth_policy: PluginAuthPolicy,
|
||||
category: Option<String>,
|
||||
) -> Result<PersonalMarketplaceUpdate, RemotePluginCatalogError> {
|
||||
let marketplace_path = home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH);
|
||||
let relative_plugin_path = personal_marketplace_relative_plugin_path(home, local_plugin_path)?;
|
||||
let mut marketplace = read_or_create_personal_marketplace(marketplace_path.as_path())?;
|
||||
let Some(marketplace_object) = marketplace.as_object_mut() else {
|
||||
return Err(invalid_marketplace_file(
|
||||
marketplace_path.as_path(),
|
||||
"personal marketplace file must contain a JSON object",
|
||||
));
|
||||
};
|
||||
let marketplace_name = marketplace_object
|
||||
.entry("name")
|
||||
.or_insert_with(|| json!(PERSONAL_MARKETPLACE_NAME))
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
invalid_marketplace_file(
|
||||
marketplace_path.as_path(),
|
||||
"marketplace name must be a string",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(|reason| {
|
||||
invalid_marketplace_file(
|
||||
marketplace_path.as_path(),
|
||||
&format!("marketplace name is invalid: {reason}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let plugins = marketplace_object
|
||||
.entry("plugins")
|
||||
.or_insert_with(|| json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| {
|
||||
invalid_marketplace_file(
|
||||
marketplace_path.as_path(),
|
||||
"marketplace plugins must be an array",
|
||||
)
|
||||
})?;
|
||||
|
||||
let new_entry = personal_marketplace_plugin_entry(
|
||||
plugin_name,
|
||||
&relative_plugin_path,
|
||||
install_policy,
|
||||
auth_policy,
|
||||
category,
|
||||
);
|
||||
|
||||
if let Some(existing_entry) = plugins
|
||||
.iter_mut()
|
||||
.find(|entry| entry.get("name").and_then(JsonValue::as_str) == Some(plugin_name))
|
||||
{
|
||||
let existing_path = existing_entry
|
||||
.get("source")
|
||||
.and_then(|source| source.get("path"))
|
||||
.and_then(JsonValue::as_str);
|
||||
if existing_path != Some(relative_plugin_path.as_str()) {
|
||||
return Err(invalid_marketplace_file(
|
||||
marketplace_path.as_path(),
|
||||
&format!(
|
||||
"marketplace already contains plugin `{plugin_name}` with a different source path"
|
||||
),
|
||||
));
|
||||
}
|
||||
*existing_entry = new_entry;
|
||||
} else {
|
||||
plugins.push(new_entry);
|
||||
}
|
||||
|
||||
let contents = serde_json::to_string_pretty(&marketplace)
|
||||
.map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?;
|
||||
write_json_atomically(marketplace_path.as_path(), &format!("{contents}\n")).map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to update personal plugin marketplace: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(PersonalMarketplaceUpdate {
|
||||
name: marketplace_name,
|
||||
path: marketplace_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_or_create_personal_marketplace(
|
||||
marketplace_path: &Path,
|
||||
) -> Result<JsonValue, RemotePluginCatalogError> {
|
||||
match std::fs::read_to_string(marketplace_path) {
|
||||
Ok(contents) => serde_json::from_str(&contents).map_err(|err| {
|
||||
invalid_marketplace_file(
|
||||
marketplace_path,
|
||||
&format!("failed to parse personal marketplace file: {err}"),
|
||||
)
|
||||
}),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(json!({
|
||||
"name": PERSONAL_MARKETPLACE_NAME,
|
||||
"interface": {
|
||||
"displayName": PERSONAL_MARKETPLACE_DISPLAY_NAME,
|
||||
},
|
||||
"plugins": [],
|
||||
})),
|
||||
Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"failed to read personal plugin marketplace: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_marketplace_plugin_entry(
|
||||
plugin_name: &str,
|
||||
relative_plugin_path: &str,
|
||||
install_policy: PluginInstallPolicy,
|
||||
auth_policy: PluginAuthPolicy,
|
||||
category: Option<String>,
|
||||
) -> JsonValue {
|
||||
let mut entry = json!({
|
||||
"name": plugin_name,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": relative_plugin_path,
|
||||
},
|
||||
"policy": {
|
||||
"installation": plugin_install_policy_value(install_policy),
|
||||
"authentication": plugin_auth_policy_value(auth_policy),
|
||||
},
|
||||
});
|
||||
if let Some(category) = category
|
||||
&& !category.trim().is_empty()
|
||||
&& let Some(object) = entry.as_object_mut()
|
||||
{
|
||||
object.insert("category".to_string(), json!(category));
|
||||
}
|
||||
entry
|
||||
}
|
||||
|
||||
fn plugin_install_policy_value(policy: PluginInstallPolicy) -> &'static str {
|
||||
match policy {
|
||||
PluginInstallPolicy::NotAvailable => "NOT_AVAILABLE",
|
||||
PluginInstallPolicy::Available => "AVAILABLE",
|
||||
PluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT",
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_auth_policy_value(policy: PluginAuthPolicy) -> &'static str {
|
||||
match policy {
|
||||
PluginAuthPolicy::OnInstall => "ON_INSTALL",
|
||||
PluginAuthPolicy::OnUse => "ON_USE",
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_marketplace_relative_plugin_path(
|
||||
home: &AbsolutePathBuf,
|
||||
local_plugin_path: &AbsolutePathBuf,
|
||||
) -> Result<String, RemotePluginCatalogError> {
|
||||
let relative = local_plugin_path
|
||||
.as_path()
|
||||
.strip_prefix(home.as_path())
|
||||
.map_err(|_| RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: local_plugin_path.to_path_buf(),
|
||||
reason: "local plugin path must be inside the home directory to be listed in the personal marketplace".to_string(),
|
||||
})?;
|
||||
let mut segments = Vec::new();
|
||||
for component in relative.components() {
|
||||
match component {
|
||||
Component::Normal(segment) => {
|
||||
let segment = segment.to_str().ok_or_else(|| {
|
||||
RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: local_plugin_path.to_path_buf(),
|
||||
reason: "local plugin path contains non-UTF-8 segments".to_string(),
|
||||
}
|
||||
})?;
|
||||
segments.push(segment.to_string());
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: local_plugin_path.to_path_buf(),
|
||||
reason:
|
||||
"local plugin path cannot be represented as a personal marketplace path"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if segments.is_empty() {
|
||||
return Err(RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: local_plugin_path.to_path_buf(),
|
||||
reason: "local plugin path must not be the home directory".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(format!("./{}", segments.join("/")))
|
||||
}
|
||||
|
||||
fn invalid_marketplace_file(path: &Path, message: &str) -> RemotePluginCatalogError {
|
||||
RemotePluginCatalogError::InvalidPluginPath {
|
||||
path: path.to_path_buf(),
|
||||
reason: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(contents.as_bytes())?;
|
||||
tmp.persist(write_path).map_err(|err| err.error)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -239,6 +239,26 @@ pub async fn download_and_install_remote_plugin_bundle(
|
||||
})?
|
||||
}
|
||||
|
||||
pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path(
|
||||
bundle: ValidatedRemotePluginBundle,
|
||||
destination: AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, RemotePluginBundleInstallError> {
|
||||
let bundle_bytes = download_remote_plugin_bundle_with_limit(
|
||||
&bundle.bundle_download_url,
|
||||
/*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
.await?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_remote_plugin_bundle_to_path(bundle, bundle_bytes, destination)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"failed to join remote plugin bundle extraction task: {err}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
async fn download_remote_plugin_bundle_with_limit(
|
||||
bundle_download_url: &str,
|
||||
max_bytes: u64,
|
||||
@@ -359,6 +379,63 @@ fn install_remote_plugin_bundle(
|
||||
.map_err(RemotePluginBundleInstallError::from)
|
||||
}
|
||||
|
||||
fn extract_remote_plugin_bundle_to_path(
|
||||
bundle: ValidatedRemotePluginBundle,
|
||||
bundle_bytes: Vec<u8>,
|
||||
destination: AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, RemotePluginBundleInstallError> {
|
||||
if destination.as_path().exists() {
|
||||
return Err(RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"plugin checkout destination already exists: {}",
|
||||
destination.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let parent = destination.as_path().parent().ok_or_else(|| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"plugin checkout destination has no parent: {}",
|
||||
destination.display()
|
||||
))
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|source| {
|
||||
RemotePluginBundleInstallError::io("failed to create plugin checkout directory", source)
|
||||
})?;
|
||||
|
||||
let extract_dir = tempfile::Builder::new()
|
||||
.prefix("remote-plugin-checkout-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|source| {
|
||||
RemotePluginBundleInstallError::io(
|
||||
"failed to create remote plugin bundle extraction directory",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
|
||||
extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?;
|
||||
let plugin_root = find_extracted_plugin_root(extract_dir.path())?;
|
||||
let manifest = crate::manifest::load_plugin_manifest(&plugin_root).ok_or_else(|| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(
|
||||
"remote plugin bundle did not contain a valid plugin.json".to_string(),
|
||||
)
|
||||
})?;
|
||||
if manifest.name != bundle.plugin_id.plugin_name {
|
||||
return Err(RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"plugin.json name `{}` does not match remote plugin name `{}`",
|
||||
manifest.name, bundle.plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
|
||||
let staged_path = extract_dir.keep();
|
||||
fs::rename(&staged_path, destination.as_path()).map_err(|source| {
|
||||
RemotePluginBundleInstallError::io(
|
||||
"failed to activate checked out plugin directory",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
fn extract_plugin_bundle_tar_gz(
|
||||
bytes: &[u8],
|
||||
destination: &Path,
|
||||
|
||||
Reference in New Issue
Block a user