feat: Track local paths for shared plugins (#20560)

When a local plugin is shared, Codex now records the local plugin path
by remote plugin id under CODEX_HOME/.tmp.

plugin/share/list includes the remote share URL and the matching local
plugin path when available, and plugin/share/delete
clears the local mapping after deleting the remote share.

Also add sharedURL to plugin/share/list.
This commit is contained in:
xl-openai
2026-05-01 00:50:12 -07:00
committed by GitHub
Unverified
parent 96d2ea9058
commit 48791920a8
14 changed files with 589 additions and 76 deletions
@@ -12358,6 +12358,31 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/v2/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
@@ -12368,7 +12393,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/PluginSummary"
"$ref": "#/definitions/v2/PluginShareListItem"
},
"type": "array"
}
@@ -9011,6 +9011,31 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
@@ -9021,7 +9046,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
"$ref": "#/definitions/PluginShareListItem"
},
"type": "array"
}
@@ -167,6 +167,31 @@
],
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -304,7 +329,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
"$ref": "#/definitions/PluginShareListItem"
},
"type": "array"
}
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginSummary } from "./PluginSummary";
export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, };
@@ -1,6 +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";
import type { PluginShareListItem } from "./PluginShareListItem";
export type PluginShareListResponse = { data: Array<PluginSummary>, };
export type PluginShareListResponse = { data: Array<PluginShareListItem>, };
@@ -283,6 +283,7 @@ export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareListItem } from "./PluginShareListItem";
export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
+38 -21
View File
@@ -4651,7 +4651,7 @@ pub struct PluginShareListParams {}
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListResponse {
pub data: Vec<PluginSummary>,
pub data: Vec<PluginShareListItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -4666,6 +4666,15 @@ pub struct PluginShareDeleteParams {
#[ts(export_to = "v2/")]
pub struct PluginShareDeleteResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListItem {
pub plugin: PluginSummary,
pub share_url: String,
pub local_plugin_path: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -10765,33 +10774,41 @@ mod tests {
}
#[test]
fn plugin_share_list_response_serializes_plugin_summaries() {
fn plugin_share_list_response_serializes_share_items() {
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,
availability: PluginAvailability::Available,
interface: None,
data: vec![PluginShareListItem {
plugin: 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,
availability: PluginAvailability::Available,
interface: None,
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
})
.unwrap(),
json!({
"data": [{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"availability": "AVAILABLE",
"interface": null,
"plugin": {
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"availability": "AVAILABLE",
"interface": null,
},
"shareUrl": "https://chatgpt.example/plugins/share/share-key-1",
"localPluginPath": null,
}],
}),
);
@@ -122,6 +122,7 @@ 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::PluginShareListItem;
use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveParams;
@@ -299,6 +300,7 @@ use codex_core_plugins::remote::RemoteMarketplace;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_FS;
@@ -387,14 +387,16 @@ impl CodexMessageProcessor {
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
plugin_path.as_path(),
config.codex_home.as_path(),
&plugin_path,
remote_plugin_id.as_deref(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
let remote_plugin_id = result.remote_plugin_id;
self.clear_plugin_related_caches();
Ok(PluginShareSaveResponse {
remote_plugin_id: result.remote_plugin_id,
remote_plugin_id,
share_url: result.share_url.unwrap_or_default(),
})
}
@@ -418,11 +420,24 @@ impl CodexMessageProcessor {
let data = codex_core_plugins::remote::list_remote_plugin_shares(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
.into_iter()
.map(remote_plugin_summary_to_info)
.map(|summary| {
let RemoteCatalogPluginShareSummary {
summary,
share_url,
local_plugin_path,
} = summary;
let plugin = remote_plugin_summary_to_info(summary);
PluginShareListItem {
plugin,
share_url: share_url.unwrap_or_default(),
local_plugin_path,
}
})
.collect();
Ok(PluginShareListResponse { data })
}
@@ -452,6 +467,7 @@ impl CodexMessageProcessor {
codex_core_plugins::remote::delete_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
@@ -12,6 +12,7 @@ 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::PluginShareListItem;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSource;
@@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
@@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
"pluginPath": expected_plugin_path.clone(),
})),
)
.await?;
@@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
}
);
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 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![PluginShareListItem {
plugin: 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,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: Some(expected_plugin_path),
}],
}
);
Ok(())
}
@@ -169,16 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
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,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
data: vec![PluginShareListItem {
plugin: 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,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
}
);
@@ -198,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;
Mock::given(method("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
@@ -227,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let response: PluginShareDeleteResponse = to_response(response)?;
assert_eq!(response, PluginShareDeleteResponse {});
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 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![PluginShareListItem {
plugin: 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,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
}
);
Ok(())
}
@@ -250,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
@@ -315,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
Ok(plugin_path)
}
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"),
"not-json",
)
}
fn write_plugin_share_local_path_mapping(
codex_home: &Path,
remote_plugin_id: &str,
plugin_path: &AbsolutePathBuf,
) -> std::io::Result<()> {
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
local_plugin_paths_by_remote_plugin_id.insert(
remote_plugin_id.to_string(),
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
);
let contents = serde_json::to_string_pretty(&json!({
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
}))
.map_err(std::io::Error::other)?;
write_file(
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
&format!("{contents}\n"),
)
}
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Err(std::io::Error::other(format!(
+10
View File
@@ -9,6 +9,7 @@ use codex_app_server_protocol::SkillInterface;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use reqwest::RequestBuilder;
use serde::Deserialize;
use std::collections::BTreeMap;
@@ -75,6 +76,13 @@ pub struct RemotePluginSummary {
pub interface: Option<PluginInterface>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginShareSummary {
pub summary: RemotePluginSummary,
pub share_url: Option<String>,
pub local_plugin_path: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginDetail {
pub marketplace_name: String,
@@ -323,6 +331,8 @@ struct RemotePluginDirectoryItem {
id: String,
name: String,
scope: RemotePluginScope,
#[serde(default)]
share_url: Option<String>,
installation_policy: PluginInstallPolicy,
authentication_policy: PluginAuthPolicy,
#[serde(rename = "status", default)]
+45 -7
View File
@@ -1,6 +1,7 @@
use super::*;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_utils_absolute_path::AbsolutePathBuf;
use flate2::Compression;
use flate2::write::GzEncoder;
use reqwest::RequestBuilder;
@@ -13,6 +14,9 @@ use std::fs;
use std::io;
use std::io::Write;
use std::path::Path;
use tracing::warn;
mod local_paths;
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
@@ -53,14 +57,15 @@ struct RemoteWorkspacePluginCreateResponse {
pub async fn save_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_path: &Path,
codex_home: &Path,
plugin_path: &AbsolutePathBuf,
remote_plugin_id: Option<&str>,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugin_path = plugin_path.to_path_buf();
let plugin_path_for_archive = plugin_path.as_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)?;
let filename = archive_filename(&plugin_path_for_archive)?;
let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?;
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
})
.await
@@ -93,6 +98,17 @@ pub async fn save_remote_plugin_share(
));
}
if let Err(err) = local_paths::record_plugin_share_local_path(
codex_home,
&response.plugin_id,
plugin_path.clone(),
) {
warn!(
remote_plugin_id = %response.plugin_id,
"failed to record plugin share local path mapping: {err}"
);
}
Ok(RemotePluginShareSaveResult {
remote_plugin_id: response.plugin_id,
share_url: response.share_url,
@@ -102,7 +118,8 @@ pub async fn save_remote_plugin_share(
pub async fn list_remote_plugin_shares(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
codex_home: &Path,
) -> Result<Vec<RemotePluginShareSummary>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
if created_plugins.is_empty() {
@@ -115,16 +132,30 @@ pub async fn list_remote_plugin_shares(
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect::<BTreeMap<_, _>>();
let local_plugin_paths =
local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| {
warn!("failed to load plugin share local path mapping: {err}");
BTreeMap::new()
});
Ok(created_plugins
.into_iter()
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
.map(|plugin| {
let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id));
let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned();
RemotePluginShareSummary {
summary,
share_url: plugin.share_url,
local_plugin_path,
}
})
.collect())
}
pub async fn delete_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
codex_home: &Path,
remote_plugin_id: &str,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
@@ -132,7 +163,14 @@ pub async fn delete_remote_plugin_share(
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
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?;
if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) {
warn!(
remote_plugin_id = %remote_plugin_id,
"failed to remove plugin share local path mapping: {err}"
);
}
Ok(())
}
async fn fetch_created_workspace_plugins(
@@ -0,0 +1,124 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::io;
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json";
static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginShareLocalPaths {
#[serde(default)]
local_plugin_paths_by_remote_plugin_id: BTreeMap<String, AbsolutePathBuf>,
}
pub(crate) fn load_plugin_share_local_paths(
codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
let _guard = lock_plugin_share_local_paths()?;
read_plugin_share_local_paths(codex_home)
}
pub(crate) fn record_plugin_share_local_path(
codex_home: &Path,
remote_plugin_id: &str,
plugin_path: AbsolutePathBuf,
) -> io::Result<()> {
let _guard = lock_plugin_share_local_paths()?;
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
mapping.insert(remote_plugin_id.to_string(), plugin_path);
write_plugin_share_local_paths(codex_home, mapping)
}
pub(crate) fn remove_plugin_share_local_path(
codex_home: &Path,
remote_plugin_id: &str,
) -> io::Result<()> {
let _guard = lock_plugin_share_local_paths()?;
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
mapping.remove(remote_plugin_id);
write_plugin_share_local_paths(codex_home, mapping)
}
fn lock_plugin_share_local_paths() -> io::Result<std::sync::MutexGuard<'static, ()>> {
PLUGIN_SHARE_LOCAL_PATHS_LOCK
.lock()
.map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}")))
}
fn read_plugin_share_local_paths(
codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
let path = plugin_share_local_paths_path(codex_home);
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(err) => return Err(err),
};
let mapping = serde_json::from_str::<PluginShareLocalPaths>(&contents).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"failed to parse plugin share local path mapping {}: {err}",
path.display()
),
)
})?;
Ok(mapping.local_plugin_paths_by_remote_plugin_id)
}
fn read_plugin_share_local_paths_for_update(
codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
match read_plugin_share_local_paths(codex_home) {
Ok(mapping) => Ok(mapping),
// This is a best-effort cache under .tmp, so malformed state should not
// permanently block future share saves or deletes.
Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()),
Err(err) => Err(err),
}
}
fn write_plugin_share_local_paths(
codex_home: &Path,
mapping: BTreeMap<String, AbsolutePathBuf>,
) -> io::Result<()> {
let path = plugin_share_local_paths_path(codex_home);
if mapping.is_empty() {
match std::fs::remove_file(&path) {
Ok(()) => return Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
}
}
let contents = serde_json::to_string_pretty(&PluginShareLocalPaths {
local_plugin_paths_by_remote_plugin_id: mapping,
})
.map_err(io::Error::other)?;
write_atomically(&path, &format!("{contents}\n"))
}
fn write_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(())
}
fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf {
codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE)
}
+103 -29
View File
@@ -3,6 +3,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_login::CodexAuth;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
@@ -49,6 +50,25 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {
plugin_path
}
fn write_plugin_share_local_path_mapping(
codex_home: &Path,
remote_plugin_id: &str,
plugin_path: &AbsolutePathBuf,
) {
write_file(
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
&format!(
"{}\n",
serde_json::to_string_pretty(&json!({
"localPluginPathsByRemotePluginId": {
remote_plugin_id: plugin_path,
},
}))
.unwrap()
),
);
}
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);
@@ -87,6 +107,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
})
}
fn remote_plugin_json_with_share_url(
plugin_id: &str,
share_url: Option<&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("share_url".to_string(), json!(share_url));
plugin
}
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 {
@@ -127,9 +159,13 @@ fn expected_plugin_interface() -> PluginInterface {
#[tokio::test]
async fn save_remote_plugin_share_creates_workspace_plugin() {
let codex_home = TempDir::new().unwrap();
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 plugin_path =
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
.unwrap()
.len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
@@ -178,6 +214,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
let result = save_remote_plugin_share(
&config,
Some(&auth),
codex_home.path(),
&plugin_path,
/*remote_plugin_id*/ None,
)
@@ -191,6 +228,10 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
}
);
assert_eq!(
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
BTreeMap::from([("plugins_123".to_string(), plugin_path)])
);
let requests = server.received_requests().await.unwrap_or_default();
let upload_request = requests
@@ -261,9 +302,13 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() {
#[tokio::test]
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
let codex_home = TempDir::new().unwrap();
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 plugin_path =
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
.unwrap()
.len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
@@ -303,9 +348,15 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
.mount(&server)
.await;
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
.await
.unwrap();
let result = save_remote_plugin_share(
&config,
Some(&auth),
codex_home.path(),
&plugin_path,
Some("plugins_123"),
)
.await
.unwrap();
assert_eq!(
result,
@@ -318,6 +369,10 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
#[tokio::test]
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
let codex_home = TempDir::new().unwrap();
let local_plugin_path =
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
@@ -332,7 +387,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"plugins": [remote_plugin_json_with_share_url(
"plugins_123",
Some("https://chatgpt.example/plugins/share/share-key-1"),
)],
"pagination": {
"next_page_token": "page-2"
},
@@ -350,7 +408,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
))
.and(query_param("pageToken", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_456")],
"plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)],
"pagination": empty_pagination_json(),
})))
.expect(1)
@@ -367,32 +425,40 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
.mount(&server)
.await;
let result = list_remote_plugin_shares(&config, Some(&auth))
let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path())
.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,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
RemotePluginShareSummary {
summary: RemotePluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
local_plugin_path: Some(local_plugin_path),
},
RemotePluginSummary {
id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
RemotePluginShareSummary {
summary: RemotePluginSummary {
id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
},
share_url: None,
local_plugin_path: None,
}
]
);
@@ -400,6 +466,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
#[tokio::test]
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
let codex_home = TempDir::new().unwrap();
let local_plugin_path =
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
@@ -413,7 +483,11 @@ async fn delete_remote_plugin_share_deletes_workspace_plugin() {
.mount(&server)
.await;
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123")
.await
.unwrap();
assert_eq!(
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
BTreeMap::new()
);
}