Compare commits

...

1 Commits

Author SHA1 Message Date
Xin Lin
7c60798665 feat: Gate shared workspace plugin sync on plugin sharing 2026-05-13 19:57:00 -07:00
9 changed files with 246 additions and 47 deletions

View File

@@ -916,6 +916,9 @@ impl PluginRequestProcessor {
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, 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 PluginShareUpdateTargetsParams {
remote_plugin_id,
discoverability,

View File

@@ -1402,6 +1402,48 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
Ok(())
}
#[tokio::test]
async fn app_server_startup_sync_uses_workspace_installed_api_with_plugin_sharing() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_remote_plugin_request_count(
&server,
"/ps/plugins/installed",
/*expected_count*/ 2,
)
.await?;
assert!(
!server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.any(|request| request
.url
.query()
.is_some_and(|query| query.contains("scope=GLOBAL")))
);
Ok(())
}
#[tokio::test]
async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -1038,6 +1038,64 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_update_targets_rejects_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api"
[features]
plugins = true
remote_plugin = true
plugin_sharing = false
"#,
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 mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/updateTargets",
Some(json!({
"remotePluginId": "plugins_123",
"discoverability": "PRIVATE",
"shareTargets": [],
})),
)
.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_eq!(error.error.message, "plugin sharing is disabled");
assert!(
server
.received_requests()
.await
.expect("wiremock should record requests")
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -35,6 +35,7 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use crate::marketplace_upgrade::configured_git_marketplace_names;
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginServiceConfig;
@@ -88,6 +89,7 @@ pub struct PluginsConfigInput {
pub config_layer_stack: ConfigLayerStack,
pub plugins_enabled: bool,
pub remote_plugin_enabled: bool,
pub plugin_sharing_enabled: bool,
pub plugin_hooks_enabled: bool,
pub chatgpt_base_url: String,
}
@@ -97,6 +99,7 @@ impl PluginsConfigInput {
config_layer_stack: ConfigLayerStack,
plugins_enabled: bool,
remote_plugin_enabled: bool,
plugin_sharing_enabled: bool,
plugin_hooks_enabled: bool,
chatgpt_base_url: String,
) -> Self {
@@ -104,10 +107,15 @@ impl PluginsConfigInput {
config_layer_stack,
plugins_enabled,
remote_plugin_enabled,
plugin_sharing_enabled,
plugin_hooks_enabled,
chatgpt_base_url,
}
}
fn remote_installed_plugin_sync_enabled(&self) -> bool {
self.remote_plugin_enabled || self.plugin_sharing_enabled
}
}
#[derive(Clone, PartialEq, Eq)]
@@ -128,6 +136,8 @@ struct CachedFeaturedPluginIds {
struct RemoteInstalledPluginsCacheRefreshRequest {
service_config: RemotePluginServiceConfig,
auth: Option<CodexAuth>,
include_global: bool,
include_workspace: bool,
notify: RemoteInstalledPluginsCacheRefreshNotify,
// App-server attaches side effects such as skills metadata invalidation and MCP refreshes when
// remote installed state changes.
@@ -589,7 +599,9 @@ impl PluginsManager {
&self,
config: &PluginsConfigInput,
) -> HashMap<String, PluginConfig> {
if !config.remote_plugin_enabled {
let include_global = config.remote_plugin_enabled;
let include_workspace = config.remote_plugin_enabled || config.plugin_sharing_enabled;
if !include_global && !include_workspace {
return HashMap::new();
}
@@ -601,7 +613,18 @@ impl PluginsManager {
return HashMap::new();
};
remote_installed_plugins_to_config(plugins, &self.store)
let filtered_plugins = plugins
.iter()
.filter(|plugin| {
if plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME {
include_global
} else {
include_workspace
}
})
.cloned()
.collect::<Vec<_>>();
remote_installed_plugins_to_config(&filtered_plugins, &self.store)
}
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
@@ -667,7 +690,9 @@ impl PluginsManager {
notify: RemoteInstalledPluginsCacheRefreshNotify,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if !config.plugins_enabled || !config.remote_plugin_enabled {
let include_global = config.remote_plugin_enabled;
let include_workspace = config.remote_plugin_enabled || config.plugin_sharing_enabled;
if !config.plugins_enabled || (!include_global && !include_workspace) {
return;
}
@@ -675,6 +700,8 @@ impl PluginsManager {
RemoteInstalledPluginsCacheRefreshRequest {
service_config: remote_plugin_service_config(config),
auth,
include_global,
include_workspace,
notify,
on_effective_plugins_changed,
},
@@ -687,7 +714,9 @@ impl PluginsManager {
auth: Option<CodexAuth>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if !config.plugins_enabled || !config.remote_plugin_enabled {
let include_global = config.remote_plugin_enabled;
let include_workspace = config.remote_plugin_enabled || config.plugin_sharing_enabled;
if !config.plugins_enabled || (!include_global && !include_workspace) {
return;
}
@@ -706,6 +735,8 @@ impl PluginsManager {
self.codex_home.clone(),
remote_plugin_service_config(config),
auth,
include_global,
include_workspace,
Some(on_local_cache_changed),
);
}
@@ -1504,7 +1535,7 @@ impl PluginsManager {
auth_manager.clone(),
);
if config.remote_plugin_enabled {
if config.remote_installed_plugin_sync_enabled() {
let config = config.clone();
let manager = Arc::clone(self);
let auth_manager = auth_manager.clone();
@@ -1764,6 +1795,8 @@ impl PluginsManager {
let installed_plugins = crate::remote::fetch_remote_installed_plugins(
&request.service_config,
request.auth.as_ref(),
/*include_global*/ request.include_global,
/*include_workspace*/ request.include_workspace,
)
.await;
match installed_plugins {

View File

@@ -625,16 +625,30 @@ fn build_remote_marketplace(
pub async fn fetch_remote_installed_plugins(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
include_global: bool,
include_workspace: bool,
) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> {
if !include_global && !include_workspace {
return Ok(Vec::new());
}
let auth = ensure_chatgpt_auth(auth)?;
let global = async {
let scope = RemotePluginScope::Global;
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
let installed_plugins = if include_global {
fetch_installed_plugins_for_scope(config, auth, scope).await?
} else {
Vec::new()
};
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let workspace = async {
let scope = RemotePluginScope::Workspace;
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
let installed_plugins = if include_workspace {
fetch_installed_plugins_for_scope(config, auth, scope).await?
} else {
Vec::new()
};
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};

View File

@@ -82,8 +82,14 @@ pub fn maybe_start_remote_installed_plugin_bundle_sync(
codex_home: PathBuf,
config: RemotePluginServiceConfig,
auth: Option<CodexAuth>,
include_global: bool,
include_workspace: bool,
on_local_cache_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if !include_global && !include_workspace {
return;
}
let Some(auth) = auth else {
return;
};
@@ -95,8 +101,14 @@ pub fn maybe_start_remote_installed_plugin_bundle_sync(
}
tokio::spawn(async move {
let result =
sync_remote_installed_plugin_bundles_once(codex_home, &config, Some(&auth)).await;
let result = sync_remote_installed_plugin_bundles_once_filtered(
codex_home,
&config,
Some(&auth),
include_global,
include_workspace,
)
.await;
match result {
Ok(outcome) => {
if outcome.changed_local_cache()
@@ -127,46 +139,68 @@ pub async fn sync_remote_installed_plugin_bundles_once(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<RemoteInstalledPluginBundleSyncOutcome, RemoteInstalledPluginBundleSyncError> {
sync_remote_installed_plugin_bundles_once_filtered(
codex_home, config, auth, /*include_global*/ true, /*include_workspace*/ true,
)
.await
}
async fn sync_remote_installed_plugin_bundles_once_filtered(
codex_home: PathBuf,
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
include_global: bool,
include_workspace: bool,
) -> Result<RemoteInstalledPluginBundleSyncOutcome, RemoteInstalledPluginBundleSyncError> {
if !include_global && !include_workspace {
return Ok(RemoteInstalledPluginBundleSyncOutcome::default());
}
let auth = ensure_chatgpt_auth(auth)?;
let global = async {
let scope = RemotePluginScope::Global;
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
config, auth, scope, /*include_download_urls*/ true,
)
.await?;
let installed_plugins = if include_global {
fetch_installed_plugins_for_scope_with_download_url(
config, auth, scope, /*include_download_urls*/ true,
)
.await?
} else {
Vec::new()
};
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let workspace = async {
let scope = RemotePluginScope::Workspace;
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
config, auth, scope, /*include_download_urls*/ true,
)
.await?;
let installed_plugins = if include_workspace {
fetch_installed_plugins_for_scope_with_download_url(
config, auth, scope, /*include_download_urls*/ true,
)
.await?
} else {
Vec::new()
};
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
let store = PluginStore::try_new(codex_home.clone())?;
let mut installed_plugin_names_by_marketplace =
BTreeMap::<String, BTreeSet<String>>::from_iter([
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
let mut cache_marketplace_names = Vec::new();
if include_global {
cache_marketplace_names.push(REMOTE_GLOBAL_MARKETPLACE_NAME);
}
if include_workspace {
cache_marketplace_names.extend([
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
]);
}
let mut installed_plugin_names_by_marketplace = BTreeMap::<String, BTreeSet<String>>::from_iter(
cache_marketplace_names
.iter()
.map(|marketplace_name| ((*marketplace_name).to_string(), BTreeSet::new())),
);
let mut installed_plugin_ids = BTreeSet::new();
let mut failed_remote_plugin_ids = BTreeSet::new();
@@ -250,6 +284,7 @@ pub async fn sync_remote_installed_plugin_bundles_once(
remove_stale_remote_plugin_caches(
codex_home.as_path(),
&installed_plugin_names_by_marketplace,
&cache_marketplace_names,
)
})
.await?
@@ -303,15 +338,10 @@ impl Drop for RemotePluginCacheMutationGuard {
fn remove_stale_remote_plugin_caches(
codex_home: &Path,
installed_plugin_names_by_marketplace: &BTreeMap<String, BTreeSet<String>>,
cache_marketplace_names: &[&str],
) -> Result<Vec<String>, String> {
let mut removed_cache_plugin_ids = Vec::new();
for marketplace_name in [
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
] {
for marketplace_name in cache_marketplace_names.iter().copied() {
let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name);
if !marketplace_root.exists() {
continue;
@@ -491,6 +521,7 @@ mod tests {
let removed = remove_stale_remote_plugin_caches(
codex_home.path(),
&installed_plugin_names_by_marketplace,
&[REMOTE_GLOBAL_MARKETPLACE_NAME],
)
.expect("cleanup while install is guarded");
assert_eq!(removed, Vec::<String>::new());
@@ -500,6 +531,7 @@ mod tests {
let removed = remove_stale_remote_plugin_caches(
codex_home.path(),
&installed_plugin_names_by_marketplace,
&[REMOTE_GLOBAL_MARKETPLACE_NAME],
)
.expect("cleanup while second install guard is still active");
assert_eq!(removed, Vec::<String>::new());
@@ -509,6 +541,7 @@ mod tests {
let removed = remove_stale_remote_plugin_caches(
codex_home.path(),
&installed_plugin_names_by_marketplace,
&[REMOTE_GLOBAL_MARKETPLACE_NAME],
)
.expect("cleanup after install guard is dropped");
assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]);
@@ -566,6 +599,12 @@ mod tests {
let removed = remove_stale_remote_plugin_caches(
codex_home.path(),
&installed_plugin_names_by_marketplace,
&[
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
],
)
.expect("cleanup private shared-with-me cache");

View File

@@ -120,6 +120,11 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin
"remote_plugin",
/*default_enabled*/ false,
),
feature_enabled(
&effective_config,
"plugin_sharing",
/*default_enabled*/ true,
),
feature_enabled(
&effective_config,
"plugin_hooks",

View File

@@ -1105,6 +1105,7 @@ impl Config {
self.config_layer_stack.clone(),
self.features.enabled(Feature::Plugins),
self.features.enabled(Feature::RemotePlugin),
self.features.enabled(Feature::PluginSharing),
self.features.enabled(Feature::PluginHooks),
self.chatgpt_base_url.clone(),
)

View File

@@ -13,8 +13,9 @@ description: Create and scaffold plugin directories for Codex with a required `.
# Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars.
# The generated folder and plugin.json name are always the same.
# Run from repo root (or replace .agents/... with the absolute path to this SKILL).
# By default creates in ~/plugins/<plugin-name>.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name>
# Default personal plugin: pass the destination explicitly.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name> \
--path ~/plugins
```
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders.
@@ -22,8 +23,11 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering:
```bash
# Personal marketplace entries default to ~/.agents/plugins/marketplace.json.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
# Personal marketplace entries live at ~/.agents/plugins/marketplace.json.
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
--path ~/plugins \
--marketplace-path ~/.agents/plugins/marketplace.json \
--with-marketplace
```
If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said