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:
xli-oai
2026-05-10 16:59:57 -07:00
committed by GitHub
parent 5248e3da2b
commit 2abdeb34d5
3 changed files with 250 additions and 4 deletions

View File

@@ -1141,6 +1141,124 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
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]
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
let codex_home = TempDir::new()?;