Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions codex-rs/app-server/tests/suite/v2/plugin_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
26 changes: 22 additions & 4 deletions codex-rs/core-plugins/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,19 +1196,37 @@ impl PluginsManager {
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
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())
{
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we display remote plugin detail iff plugin is:

  1. installed
  2. marketplace is git backed
  3. plugin root is valid
  4. plugin manifest exists

let marketplace_category = interface
.as_ref()
.and_then(|interface| interface.category.clone());
interface = plugin_interface_with_marketplace_category(
manifest.interface,
marketplace_category,
);
}

Some(ConfiguredMarketplacePlugin {
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
// plugin entries from duplicate marketplace files intentionally
// resolve to the first discovered source.
id: plugin_key.clone(),
installed: installed_plugins.contains(&plugin_key),
enabled: enabled_plugins.contains(&plugin_key),
id: plugin_key,
installed,
enabled,
name: plugin.name,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
keywords: plugin.keywords,
interface,
})
})
.collect::<Vec<_>>();
Expand Down
110 changes: 110 additions & 0 deletions codex-rs/core-plugins/src/manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading