use super::*;use crate::plugin_bundle_archive::PluginBundlePackError;use crate::plugin_bundle_archive::pack_plugin_bundle_tar_gz;use codex_login::CodexAuth;use codex_login::default_client::build_reqwest_client;use codex_utils_absolute_path::AbsolutePathBuf;use reqwest::RequestBuilder;use reqwest::StatusCode;use serde::Deserialize;use serde::Serialize;use std::collections::BTreeMap;use std::io;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, pub share_url: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Default)]pub struct RemotePluginShareAccessPolicy { pub discoverability: Option<RemotePluginShareDiscoverability>, pub share_targets: Option<Vec<RemotePluginShareTarget>>,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "SCREAMING_SNAKE_CASE")]pub enum RemotePluginShareDiscoverability { Listed, Unlisted, Private,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]#[serde(rename_all = "SCREAMING_SNAKE_CASE")]pub enum RemotePluginShareUpdateDiscoverability { Unlisted, Private,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum RemotePluginSharePrincipalType { User, Group, Workspace,}#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct RemotePluginShareTarget { pub principal_type: RemotePluginSharePrincipalType, pub principal_id: String, pub role: RemotePluginShareTargetRole,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]pub struct RemotePluginSharePrincipal { pub principal_type: RemotePluginSharePrincipalType, pub principal_id: String, pub role: RemotePluginSharePrincipalRole, pub name: String,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum RemotePluginShareTargetRole { Reader, Editor,}#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum RemotePluginSharePrincipalRole { Reader, Editor, Owner,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct RemotePluginShareUpdateTargetsResult { pub principals: Vec<RemotePluginSharePrincipal>, pub discoverability: RemotePluginShareDiscoverability,}#[derive(Debug, Clone, PartialEq, Eq, Serialize)]struct RemoteWorkspacePluginUploadUrlRequest<'a> { filename: &'a str, mime_type: &'a str, size_bytes: usize, #[serde(skip_serializing_if = "Option::is_none")] plugin_id: Option<&'a str>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemoteWorkspacePluginUploadUrlResponse { file_id: String, upload_url: String, etag: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Serialize)]struct RemoteWorkspacePluginCreateRequest { file_id: String, etag: String, #[serde(skip_serializing_if = "Option::is_none")] discoverability: Option<RemotePluginShareDiscoverability>, #[serde(skip_serializing_if = "Option::is_none")] share_targets: Option<Vec<RemotePluginShareTarget>>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemoteWorkspacePluginCreateResponse { plugin_id: String, share_url: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Serialize)]struct RemotePluginShareUpdateTargetsRequest { discoverability: RemotePluginShareUpdateDiscoverability, targets: Vec<RemotePluginShareTarget>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginShareUpdateTargetsResponse { principals: Vec<RemotePluginSharePrincipal>, discoverability: RemotePluginShareDiscoverability,}pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, access_policy: RemotePluginShareAccessPolicy,) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; 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_for_archive)?; let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?; Ok::<_, RemotePluginCatalogError>((filename, archive_bytes)) }) .await .map_err(RemotePluginCatalogError::ArchiveJoin)??; let upload = create_workspace_plugin_upload( config, auth, &filename, archive_bytes.len(), remote_plugin_id, ) .await?; let etag = upload .etag .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; let share_targets = access_policy.share_targets; let share_targets = ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?; let response = finalize_workspace_plugin_upload( config, auth, remote_plugin_id, RemoteWorkspacePluginCreateRequest { file_id: upload.file_id, etag, discoverability: access_policy.discoverability, share_targets, }, ) .await?; if response.plugin_id.is_empty() { return Err(RemotePluginCatalogError::UnexpectedResponse( "workspace plugin create response did not include a plugin id".to_string(), )); } 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, })}pub async fn list_remote_plugin_shares( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, 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() { return Ok(Vec::new()); } let installed_by_id = fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace) .await? .into_iter() .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::<BTreeMap<_, _>>(); let local_plugin_paths = local_paths::load_plugin_share_local_paths(codex_home).map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( "failed to load plugin share local path mapping: {err}" )) })?; created_plugins .into_iter() .map(|plugin| { let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id))?; if summary .share_context .as_ref() .and_then(|context| context.share_principals.as_ref()) .is_none() { return Err(RemotePluginCatalogError::UnexpectedResponse(format!( "created workspace plugin `{}` did not include share_principals", plugin.id ))); } let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); Ok(RemotePluginShareSummary { summary, local_plugin_path, }) }) .collect()}pub fn load_plugin_share_remote_ids_by_local_path( codex_home: &Path,) -> io::Result<BTreeMap<AbsolutePathBuf, String>> { let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?; local_paths .into_iter() .map(|(remote_plugin_id, local_plugin_path)| { if !is_valid_remote_plugin_id(&remote_plugin_id) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "invalid remote plugin id in share local path mapping: {remote_plugin_id}" ), )); } Ok((local_plugin_path, remote_plugin_id)) }) .collect()}pub async fn delete_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, remote_plugin_id: &str,) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); let client = build_reqwest_client(); let request = authenticated_request(client.delete(&url), auth)?; send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; 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(())}pub async fn update_remote_plugin_share_targets( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, remote_plugin_id: &str, targets: Vec<RemotePluginShareTarget>, discoverability: RemotePluginShareUpdateDiscoverability,) -> Result<RemotePluginShareUpdateTargetsResult, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let target_discoverability = match discoverability { RemotePluginShareUpdateDiscoverability::Unlisted => { RemotePluginShareDiscoverability::Unlisted } RemotePluginShareUpdateDiscoverability::Private => { RemotePluginShareDiscoverability::Private } }; let targets = ensure_unlisted_workspace_target(auth, Some(target_discoverability), Some(targets))? .unwrap_or_default(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares"); let client = build_reqwest_client(); let request = authenticated_request(client.put(&url), auth)?.json( &RemotePluginShareUpdateTargetsRequest { discoverability, targets, }, ); let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; Ok(RemotePluginShareUpdateTargetsResult { principals: response.principals, discoverability: response.discoverability, })}fn ensure_unlisted_workspace_target( auth: &CodexAuth, discoverability: Option<RemotePluginShareDiscoverability>, targets: Option<Vec<RemotePluginShareTarget>>,) -> Result<Option<Vec<RemotePluginShareTarget>>, RemotePluginCatalogError> { if discoverability != Some(RemotePluginShareDiscoverability::Unlisted) { return Ok(targets); } let account_id = auth.get_account_id().ok_or_else(|| { RemotePluginCatalogError::UnexpectedResponse( "workspace plugin share requires an account id".to_string(), ) })?; let mut targets = targets.unwrap_or_default(); if !targets.iter().any(|target| { target.principal_type == RemotePluginSharePrincipalType::Workspace && target.principal_id == account_id }) { targets.push(RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::Workspace, principal_id: account_id, role: RemotePluginShareTargetRole::Reader, }); } Ok(Some(targets))}async fn fetch_created_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth,) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = get_created_workspace_plugins_page(config, auth, page_token.as_deref()).await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; }; page_token = Some(next_page_token); } Ok(plugins)}async fn get_created_workspace_plugins_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, page_token: Option<&str>,) -> Result<RemotePluginListResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/workspace/created"); let client = build_reqwest_client(); let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } send_and_decode(request, &url).await}async fn create_workspace_plugin_upload( config: &RemotePluginServiceConfig, auth: &CodexAuth, filename: &str, size_bytes: usize, remote_plugin_id: Option<&str>,) -> Result<RemoteWorkspacePluginUploadUrlResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/public/plugins/workspace/upload-url"); let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?.json( &RemoteWorkspacePluginUploadUrlRequest { filename, mime_type: "application/gzip", size_bytes, plugin_id: remote_plugin_id, }, ); send_and_decode(request, &url).await}async fn put_workspace_plugin_upload( upload_url: &str, archive_bytes: Vec<u8>,) -> Result<(), RemotePluginCatalogError> { let client = build_reqwest_client(); let request = client .put(upload_url) .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) .header("x-ms-blob-type", "BlockBlob") .header("Content-Type", "application/gzip") .body(archive_bytes); let response = request .send() .await .map_err(|source| RemotePluginCatalogError::Request { url: "workspace plugin upload URL".to_string(), source, })?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if ![StatusCode::OK, StatusCode::CREATED].contains(&status) { return Err(RemotePluginCatalogError::UnexpectedStatus { url: "workspace plugin upload URL".to_string(), status, body, }); } Ok(())}async fn finalize_workspace_plugin_upload( config: &RemotePluginServiceConfig, auth: &CodexAuth, remote_plugin_id: Option<&str>, body: RemoteWorkspacePluginCreateRequest,) -> Result<RemoteWorkspacePluginCreateResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = if let Some(remote_plugin_id) = remote_plugin_id { format!("{base_url}/public/plugins/workspace/{remote_plugin_id}") } else { format!("{base_url}/public/plugins/workspace") }; let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?.json(&body); send_and_decode(request, &url).await}fn archive_filename(plugin_path: &Path) -> Result<String, RemotePluginCatalogError> { let plugin_name = plugin_path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| RemotePluginCatalogError::InvalidPluginPath { path: plugin_path.to_path_buf(), reason: "plugin path must end in a valid UTF-8 directory name".to_string(), })?; Ok(format!("{plugin_name}.tar.gz"))}fn archive_plugin_for_upload(plugin_path: &Path) -> Result<Vec<u8>, RemotePluginCatalogError> { archive_plugin_for_upload_with_limit(plugin_path, REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES)}fn archive_plugin_for_upload_with_limit( plugin_path: &Path, max_bytes: usize,) -> Result<Vec<u8>, RemotePluginCatalogError> { pack_plugin_bundle_tar_gz(plugin_path, max_bytes).map_err(|err| match err { PluginBundlePackError::InvalidPluginPath { path, reason } => { RemotePluginCatalogError::InvalidPluginPath { path, reason } } PluginBundlePackError::ArchiveTooLarge { bytes, max_bytes } => { RemotePluginCatalogError::ArchiveTooLarge { bytes, max_bytes } } PluginBundlePackError::Io { source } => RemotePluginCatalogError::Archive { path: plugin_path.to_path_buf(), source, }, })}async fn send_and_expect_status( request: RequestBuilder, url_for_error: &str, expected_statuses: &[StatusCode],) -> Result<(), RemotePluginCatalogError> { let response = request .send() .await .map_err(|source| RemotePluginCatalogError::Request { url: url_for_error.to_string(), source, })?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if !expected_statuses.contains(&status) { return Err(RemotePluginCatalogError::UnexpectedStatus { url: url_for_error.to_string(), status, body, }); } Ok(())}#[cfg(test)]mod tests;