mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Read cached metadata for installed Git plugins (#20825)
## Summary - Populate `plugin/list` interface metadata for installed Git-sourced marketplace plugins from the active cached plugin bundle. - Preserve marketplace category precedence so list behavior matches `plugin/read`. - Keep existing fallback behavior when the cache or manifest is missing or invalid. ## Test Plan - `cd codex-rs && just fmt` - `cd codex-rs && cargo test -p codex-core-plugins list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning` - `cd codex-rs && cargo test -p codex-app-server plugin_list_returns_installed_git_source_interface_from_cache` - `cd codex-rs && just fix -p codex-core-plugins` - `cd codex-rs && just fix -p codex-app-server` - `git diff --check` Server-truth check: OpenAI monorepo app-server generated types already expose `PluginSummary.interface`, and the webview consumes it for plugin cards. This PR keeps the protocol/schema unchanged and fills the existing field from the cached installed bundle for Git-backed cross-repo plugins.
This commit is contained in:
@@ -1141,6 +1141,124 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_list_returns_installed_git_source_interface_from_cache() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let repo_root = TempDir::new()?;
|
||||||
|
let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");
|
||||||
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||||
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||||
|
std::fs::write(
|
||||||
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [
|
||||||
|
{{
|
||||||
|
"name": "toolkit",
|
||||||
|
"source": {{
|
||||||
|
"source": "git-subdir",
|
||||||
|
"url": "{missing_remote_repo_url}",
|
||||||
|
"path": "plugins/toolkit"
|
||||||
|
}},
|
||||||
|
"category": "Developer Tools"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}"#
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let cached_plugin_root = codex_home.path().join("plugins/cache/debug/toolkit/local");
|
||||||
|
std::fs::create_dir_all(cached_plugin_root.join(".codex-plugin"))?;
|
||||||
|
std::fs::write(
|
||||||
|
cached_plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r##"{
|
||||||
|
"name": "toolkit",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Toolkit",
|
||||||
|
"shortDescription": "Search cached data",
|
||||||
|
"category": "Cached Category",
|
||||||
|
"brandColor": "#3B82F6",
|
||||||
|
"composerIcon": "./assets/icon.png",
|
||||||
|
"logo": "./assets/logo.png"
|
||||||
|
}
|
||||||
|
}"##,
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join("config.toml"),
|
||||||
|
r#"[features]
|
||||||
|
plugins = true
|
||||||
|
|
||||||
|
[plugins."toolkit@debug"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let request_id = mcp
|
||||||
|
.send_plugin_list_request(PluginListParams {
|
||||||
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||||
|
marketplace_kinds: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let response: PluginListResponse = to_response(response)?;
|
||||||
|
|
||||||
|
let plugin = response
|
||||||
|
.marketplaces
|
||||||
|
.iter()
|
||||||
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||||
|
.find(|plugin| plugin.name == "toolkit")
|
||||||
|
.expect("expected toolkit entry");
|
||||||
|
|
||||||
|
assert_eq!(plugin.id, "toolkit@debug");
|
||||||
|
assert_eq!(plugin.installed, true);
|
||||||
|
assert_eq!(plugin.enabled, true);
|
||||||
|
assert_eq!(
|
||||||
|
plugin.source,
|
||||||
|
PluginSource::Git {
|
||||||
|
url: missing_remote_repo_url,
|
||||||
|
path: Some("plugins/toolkit".to_string()),
|
||||||
|
ref_name: None,
|
||||||
|
sha: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let interface = plugin
|
||||||
|
.interface
|
||||||
|
.as_ref()
|
||||||
|
.expect("expected cached plugin interface");
|
||||||
|
assert_eq!(interface.display_name.as_deref(), Some("Toolkit"));
|
||||||
|
assert_eq!(
|
||||||
|
interface.short_description.as_deref(),
|
||||||
|
Some("Search cached data")
|
||||||
|
);
|
||||||
|
assert_eq!(interface.category.as_deref(), Some("Developer Tools"));
|
||||||
|
assert_eq!(interface.brand_color.as_deref(), Some("#3B82F6"));
|
||||||
|
let canonical_cached_plugin_root = std::fs::canonicalize(&cached_plugin_root)?;
|
||||||
|
assert_eq!(
|
||||||
|
interface.composer_icon,
|
||||||
|
Some(AbsolutePathBuf::try_from(
|
||||||
|
canonical_cached_plugin_root.join("assets/icon.png")
|
||||||
|
)?)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
interface.logo,
|
||||||
|
Some(AbsolutePathBuf::try_from(
|
||||||
|
canonical_cached_plugin_root.join("assets/logo.png")
|
||||||
|
)?)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|||||||
@@ -1196,19 +1196,37 @@ impl PluginsManager {
|
|||||||
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
|
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let installed = installed_plugins.contains(&plugin_key);
|
||||||
|
let enabled = enabled_plugins.contains(&plugin_key);
|
||||||
|
let mut interface = plugin.interface;
|
||||||
|
if installed
|
||||||
|
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
|
||||||
|
&& let Ok(plugin_id) =
|
||||||
|
PluginId::new(plugin.name.clone(), marketplace_name.clone())
|
||||||
|
&& let Some(plugin_root) = self.store.active_plugin_root(&plugin_id)
|
||||||
|
&& let Some(manifest) = load_plugin_manifest(plugin_root.as_path())
|
||||||
|
{
|
||||||
|
let marketplace_category = interface
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|interface| interface.category.clone());
|
||||||
|
interface = plugin_interface_with_marketplace_category(
|
||||||
|
manifest.interface,
|
||||||
|
marketplace_category,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Some(ConfiguredMarketplacePlugin {
|
Some(ConfiguredMarketplacePlugin {
|
||||||
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
|
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
|
||||||
// plugin entries from duplicate marketplace files intentionally
|
// plugin entries from duplicate marketplace files intentionally
|
||||||
// resolve to the first discovered source.
|
// resolve to the first discovered source.
|
||||||
id: plugin_key.clone(),
|
id: plugin_key,
|
||||||
installed: installed_plugins.contains(&plugin_key),
|
installed,
|
||||||
enabled: enabled_plugins.contains(&plugin_key),
|
enabled,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
source: plugin.source,
|
source: plugin.source,
|
||||||
policy: plugin.policy,
|
policy: plugin.policy,
|
||||||
interface: plugin.interface,
|
|
||||||
keywords: plugin.keywords,
|
keywords: plugin.keywords,
|
||||||
|
interface,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -2039,6 +2039,116 @@ enabled = false
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo_root = tmp.path().join("repo");
|
||||||
|
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
||||||
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||||
|
write_file(
|
||||||
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||||
|
&format!(
|
||||||
|
r#"{{
|
||||||
|
"name": "debug",
|
||||||
|
"plugins": [
|
||||||
|
{{
|
||||||
|
"name": "toolkit",
|
||||||
|
"source": {{
|
||||||
|
"source": "git-subdir",
|
||||||
|
"url": "{missing_remote_repo_url}",
|
||||||
|
"path": "plugins/toolkit"
|
||||||
|
}},
|
||||||
|
"category": "Developer Tools"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}"#
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
|
||||||
|
write_file(
|
||||||
|
&cached_plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r##"{
|
||||||
|
"name": "toolkit",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Toolkit",
|
||||||
|
"shortDescription": "Search cached data",
|
||||||
|
"category": "Cached Category",
|
||||||
|
"brandColor": "#3B82F6",
|
||||||
|
"composerIcon": "./assets/icon.png",
|
||||||
|
"logo": "./assets/logo.png",
|
||||||
|
"screenshots": ["./assets/screenshot.png"]
|
||||||
|
}
|
||||||
|
}"##,
|
||||||
|
);
|
||||||
|
write_file(
|
||||||
|
&tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"[features]
|
||||||
|
plugins = true
|
||||||
|
|
||||||
|
[plugins."toolkit@debug"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = load_config(tmp.path(), &repo_root).await;
|
||||||
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||||
|
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||||
|
.unwrap()
|
||||||
|
.marketplaces;
|
||||||
|
|
||||||
|
let marketplace = marketplaces
|
||||||
|
.into_iter()
|
||||||
|
.find(|marketplace| marketplace.name == "debug")
|
||||||
|
.expect("debug marketplace should be listed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
marketplace.plugins,
|
||||||
|
vec![ConfiguredMarketplacePlugin {
|
||||||
|
id: "toolkit@debug".to_string(),
|
||||||
|
name: "toolkit".to_string(),
|
||||||
|
source: MarketplacePluginSource::Git {
|
||||||
|
url: missing_remote_repo_url,
|
||||||
|
path: Some("plugins/toolkit".to_string()),
|
||||||
|
ref_name: None,
|
||||||
|
sha: None,
|
||||||
|
},
|
||||||
|
policy: MarketplacePluginPolicy {
|
||||||
|
installation: MarketplacePluginInstallPolicy::Available,
|
||||||
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||||
|
products: None,
|
||||||
|
},
|
||||||
|
interface: Some(PluginManifestInterface {
|
||||||
|
display_name: Some("Toolkit".to_string()),
|
||||||
|
short_description: Some("Search cached data".to_string()),
|
||||||
|
category: Some("Developer Tools".to_string()),
|
||||||
|
brand_color: Some("#3B82F6".to_string()),
|
||||||
|
composer_icon: Some(
|
||||||
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(),
|
||||||
|
),
|
||||||
|
logo: Some(
|
||||||
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(),
|
||||||
|
),
|
||||||
|
screenshots: vec![
|
||||||
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png"))
|
||||||
|
.unwrap(),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
keywords: Vec::new(),
|
||||||
|
installed: true,
|
||||||
|
enabled: true,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!tmp.path()
|
||||||
|
.join("plugins/.marketplace-plugin-source-staging")
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
|
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user