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
parent 96d2ea9058
commit 48791920a8
14 changed files with 589 additions and 76 deletions
+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()
);
}