mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Support npm marketplace plugin sources (#29375)
## Why
Marketplace source deserialization treated `{"source":"npm", ...}` as
unsupported. The loader logged and skipped the entry, so npm-backed
plugins never appeared in `plugin list --available` and `plugin add`
returned "plugin not found".
Codex plugins are installed from a plugin root, not from an npm
dependency tree. For npm-backed marketplace entries, Codex should fetch
the published package contents without running package scripts or
installing unrelated dependencies.
## What changed
- Add `npm` marketplace plugin sources with `package`, optional semver
`version` or version range, and optional HTTPS `registry`.
- Reject unsafe npm source fields before materialization, including
invalid package names, non-semver version selectors, plaintext or
credential-bearing registry URLs, and registry query/fragment data.
- Materialize npm plugins with `npm pack --ignore-scripts`, then unpack
the resulting tarball through the existing hardened plugin bundle
extractor.
- Enforce npm archive and extracted-size limits, require the standard
npm `package/` archive root, and verify the extracted `package.json`
name matches the requested package before installing.
- Keep plugin listings, install-source descriptions, CLI JSON/human
output, app-server v2 `PluginSource`, TUI source summaries, regenerated
schema fixtures, and app-server documentation in sync.
## Impact
Marketplaces can distribute Codex plugins from public or configured
private HTTPS npm registries using the same install flow as existing
materialized plugin sources. `npm` must be available on `PATH` when an
npm-backed plugin is installed.
Fixes #27831
## Validation
- `just write-app-server-schema`
- `just test -p codex-core-plugins -p codex-app-server-protocol -p
codex-app-server -p codex-cli`
- npm/schema/core-plugin coverage passed in the run.
- The full focused command finished with `1739 passed`, `11 failed`, and
`6 timed out`; the failures were unrelated local app-server environment
failures from `sandbox-exec: sandbox_apply: Operation not permitted`
plus one missing `test_stdio_server` helper binary.
- Installed an npm-published Codex plugin package through a throwaway
local marketplace and throwaway `CODEX_HOME` to exercise the real npm
materialization path end to end.
This commit is contained in:
committed by
GitHub
Unverified
parent
526f495f3a
commit
6509f3148a
+34
@@ -14207,6 +14207,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
+34
@@ -10611,6 +10611,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
@@ -413,6 +413,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
@@ -413,6 +413,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
@@ -553,6 +553,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
@@ -369,6 +369,40 @@
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"registry": {
|
||||
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"npm"
|
||||
],
|
||||
"title": "NpmPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "Optional npm version or version range.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"package",
|
||||
"type"
|
||||
],
|
||||
"title": "NpmPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
|
||||
@@ -3,4 +3,12 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" };
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "npm", package: string,
|
||||
/**
|
||||
* Optional npm version or version range.
|
||||
*/
|
||||
version: string | null,
|
||||
/**
|
||||
* Optional HTTPS registry URL. Authentication stays in the user's npm config.
|
||||
*/
|
||||
registry: string | null, } | { "type": "remote" };
|
||||
|
||||
@@ -744,6 +744,15 @@ pub enum PluginSource {
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Npm {
|
||||
package: String,
|
||||
/// Optional npm version or version range.
|
||||
version: Option<String>,
|
||||
/// Optional HTTPS registry URL. Authentication stays in the user's npm config.
|
||||
registry: Option<String>,
|
||||
},
|
||||
/// The plugin is available in the remote catalog. Download metadata is
|
||||
/// kept server-side and is not exposed through the app-server API.
|
||||
Remote,
|
||||
|
||||
@@ -2927,7 +2927,7 @@ fn skills_extra_roots_set_params_rejects_relative_roots() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_source_serializes_local_git_and_remote_variants() {
|
||||
fn plugin_source_serializes_local_git_npm_and_remote_variants() {
|
||||
let local_path = if cfg!(windows) {
|
||||
r"C:\plugins\linear"
|
||||
} else {
|
||||
@@ -2961,6 +2961,21 @@ fn plugin_source_serializes_local_git_and_remote_variants() {
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSource::Npm {
|
||||
package: "@acme/plugin".to_string(),
|
||||
version: Some("^1.2.0".to_string()),
|
||||
registry: Some("https://npm.example.com".to_string()),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"type": "npm",
|
||||
"package": "@acme/plugin",
|
||||
"version": "^1.2.0",
|
||||
"registry": "https://npm.example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSource::Remote).unwrap(),
|
||||
json!({
|
||||
|
||||
@@ -101,6 +101,15 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS
|
||||
ref_name,
|
||||
sha,
|
||||
},
|
||||
MarketplacePluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
} => PluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +143,7 @@ fn share_context_for_source(
|
||||
creator_name: None,
|
||||
share_principals: None,
|
||||
}),
|
||||
MarketplacePluginSource::Git { .. } => None,
|
||||
MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,20 @@ pub async fn run_plugin_list(
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
codex_core_plugins::marketplace::MarketplacePluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
} => {
|
||||
let mut parts = vec![package.clone()];
|
||||
if let Some(version) = version {
|
||||
parts.push(format!("version `{version}`"));
|
||||
}
|
||||
if let Some(registry) = registry {
|
||||
parts.push(format!("registry `{registry}`"));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
};
|
||||
plugin_width = plugin_width.max(plugin.id.len());
|
||||
status_width = status_width.max(state.len());
|
||||
@@ -412,6 +426,13 @@ enum JsonPluginSource {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sha: Option<String>,
|
||||
},
|
||||
Npm {
|
||||
package: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
registry: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl JsonPluginSource {
|
||||
@@ -437,6 +458,15 @@ impl JsonPluginSource {
|
||||
ref_name,
|
||||
sha,
|
||||
} => Self::Git { url, ref_name, sha },
|
||||
MarketplacePluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
} => Self::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod marketplace_add;
|
||||
mod marketplace_policy;
|
||||
pub mod marketplace_remove;
|
||||
pub mod marketplace_upgrade;
|
||||
mod npm_source;
|
||||
mod plugin_bundle_archive;
|
||||
mod provider;
|
||||
pub mod remote;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::find_marketplace_plugin;
|
||||
use crate::marketplace::list_marketplaces;
|
||||
use crate::marketplace::load_marketplace;
|
||||
use crate::npm_source::materialize_npm_plugin_source;
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::store::PluginStore;
|
||||
@@ -1358,6 +1359,22 @@ pub fn materialize_marketplace_plugin_source(
|
||||
_tempdir: Some(tempdir),
|
||||
})
|
||||
}
|
||||
MarketplacePluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
} => {
|
||||
let (path, tempdir) = materialize_npm_plugin_source(
|
||||
codex_home,
|
||||
package,
|
||||
version.as_deref(),
|
||||
registry.as_deref(),
|
||||
)?;
|
||||
Ok(MaterializedMarketplacePluginSource {
|
||||
path,
|
||||
_tempdir: Some(tempdir),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1578,7 +1578,7 @@ impl PluginsManager {
|
||||
let mut local_version = plugin.local_version;
|
||||
let manifest_fallback = plugin.manifest_fallback.clone();
|
||||
if installed
|
||||
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
|
||||
&& plugin.source.is_install_materialized()
|
||||
&& let Some(plugin_id) = plugin_id.as_ref()
|
||||
&& let Some(plugin_root) = self.store.active_plugin_root(plugin_id)
|
||||
&& let Some(manifest) = load_plugin_manifest(plugin_root.as_path())
|
||||
@@ -1741,7 +1741,7 @@ impl PluginsManager {
|
||||
}
|
||||
})?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && !plugin.installed {
|
||||
if plugin.source.is_install_materialized() && !plugin.installed {
|
||||
let description = remote_plugin_install_required_description(&plugin.source);
|
||||
return Ok(PluginDetail {
|
||||
id: plugin_key,
|
||||
@@ -1766,8 +1766,7 @@ impl PluginsManager {
|
||||
});
|
||||
}
|
||||
|
||||
let source_path =
|
||||
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && plugin.installed {
|
||||
let source_path = if plugin.source.is_install_materialized() && plugin.installed {
|
||||
self.store.active_plugin_root(&plugin_id).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"installed plugin cache entry is missing for {plugin_key}"
|
||||
@@ -2474,10 +2473,29 @@ pub(crate) fn remote_plugin_install_required_description(
|
||||
parts.join(", ")
|
||||
}
|
||||
MarketplacePluginSource::Local { path } => path.as_path().display().to_string(),
|
||||
MarketplacePluginSource::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
} => {
|
||||
let mut parts = vec![package.clone()];
|
||||
if let Some(version) = version {
|
||||
parts.push(format!("version `{version}`"));
|
||||
}
|
||||
if let Some(registry) = registry {
|
||||
parts.push(format!("registry `{registry}`"));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
};
|
||||
|
||||
let source_kind = if matches!(source, MarketplacePluginSource::Npm { .. }) {
|
||||
"an npm plugin"
|
||||
} else {
|
||||
"a cross-repo plugin"
|
||||
};
|
||||
format!(
|
||||
"This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {source_description}."
|
||||
"This is {source_kind}. Install it to view more detailed information. The source of the plugin is {source_description}."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ impl MarketplacePluginManifestFallback {
|
||||
}
|
||||
|
||||
pub(crate) fn parse_for_listing(&self) -> Option<crate::manifest::PluginManifest> {
|
||||
// Git sources have no plugin root before install. Parse against a host-native synthetic
|
||||
// absolute root, then discard path-bearing fields so listings expose metadata only.
|
||||
// Materialized sources have no plugin root before install. Parse against a host-native
|
||||
// synthetic absolute root, then discard path-bearing fields so listings expose metadata only.
|
||||
let plugin_root = Path::new(if cfg!(windows) { r"C:\" } else { "/" });
|
||||
let mut manifest = crate::manifest::parse_plugin_manifest(
|
||||
plugin_root,
|
||||
@@ -133,6 +133,23 @@ pub enum MarketplacePluginSource {
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
Npm {
|
||||
package: String,
|
||||
version: Option<String>,
|
||||
registry: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NpmPackageScope {
|
||||
Scoped,
|
||||
Unscoped,
|
||||
}
|
||||
|
||||
impl MarketplacePluginSource {
|
||||
pub(crate) fn is_install_materialized(&self) -> bool {
|
||||
matches!(self, Self::Git { .. } | Self::Npm { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -513,10 +530,12 @@ fn resolve_marketplace_plugin_entry(
|
||||
None
|
||||
}
|
||||
}
|
||||
MarketplacePluginSource::Git { .. } if manifest_fallback.has_metadata => {
|
||||
MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. }
|
||||
if manifest_fallback.has_metadata =>
|
||||
{
|
||||
manifest_fallback.parse_for_listing()
|
||||
}
|
||||
MarketplacePluginSource::Git { .. } => None,
|
||||
MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } => None,
|
||||
};
|
||||
let interface = plugin_interface_with_marketplace_category(
|
||||
manifest
|
||||
@@ -610,6 +629,17 @@ fn resolve_plugin_source(
|
||||
ref_name: normalize_optional_git_selector(&ref_name),
|
||||
sha: normalize_optional_git_selector(&sha),
|
||||
}),
|
||||
RawMarketplaceManifestPluginSource::Object(
|
||||
RawMarketplaceManifestPluginSourceObject::Npm {
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
},
|
||||
) => Ok(MarketplacePluginSource::Npm {
|
||||
package: normalize_npm_package(marketplace_path, &package)?,
|
||||
version: normalize_optional_npm_version(marketplace_path, version)?,
|
||||
registry: normalize_optional_npm_registry(marketplace_path, registry)?,
|
||||
}),
|
||||
RawMarketplaceManifestPluginSource::Unsupported(_) => {
|
||||
unreachable!("unsupported plugin sources should be filtered before resolution")
|
||||
}
|
||||
@@ -748,6 +778,118 @@ fn normalize_optional_git_selector(value: &Option<String>) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalize_npm_package(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
package: &str,
|
||||
) -> Result<String, MarketplaceError> {
|
||||
let package = package.trim();
|
||||
let package_scope = if package.starts_with('@') {
|
||||
NpmPackageScope::Scoped
|
||||
} else {
|
||||
NpmPackageScope::Unscoped
|
||||
};
|
||||
let segments = if let Some(scoped_package) = package.strip_prefix('@') {
|
||||
scoped_package.split('/').collect::<Vec<_>>()
|
||||
} else {
|
||||
package.split('/').collect::<Vec<_>>()
|
||||
};
|
||||
let expected_segments = match package_scope {
|
||||
NpmPackageScope::Scoped => 2,
|
||||
NpmPackageScope::Unscoped => 1,
|
||||
};
|
||||
if package.is_empty()
|
||||
|| segments.len() != expected_segments
|
||||
|| segments
|
||||
.iter()
|
||||
.any(|segment| !is_valid_npm_package_segment(segment, package_scope))
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("invalid npm plugin source package: {package}"),
|
||||
});
|
||||
}
|
||||
Ok(package.to_string())
|
||||
}
|
||||
|
||||
fn is_valid_npm_package_segment(segment: &str, package_scope: NpmPackageScope) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment != "."
|
||||
&& segment != ".."
|
||||
&& (package_scope == NpmPackageScope::Scoped
|
||||
|| !matches!(segment.chars().next(), Some('.' | '_')))
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
fn normalize_optional_npm_version(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
version: Option<String>,
|
||||
) -> Result<Option<String>, MarketplaceError> {
|
||||
let Some(version) = normalize_optional_npm_source_field(marketplace_path, version, "version")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_registry_npm_version_selector(&version) {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("npm plugin source version must use the registry: {version}"),
|
||||
});
|
||||
}
|
||||
Ok(Some(version))
|
||||
}
|
||||
|
||||
fn is_registry_npm_version_selector(version: &str) -> bool {
|
||||
version != "." && version != ".." && !version.chars().any(|ch| matches!(ch, '/' | '\\' | ':'))
|
||||
}
|
||||
|
||||
fn normalize_optional_npm_registry(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
registry: Option<String>,
|
||||
) -> Result<Option<String>, MarketplaceError> {
|
||||
let Some(registry) =
|
||||
normalize_optional_npm_source_field(marketplace_path, registry, "registry")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed =
|
||||
url::Url::parse(®istry).map_err(|_| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("invalid npm plugin source registry: {registry}"),
|
||||
})?;
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("invalid npm plugin source registry: {registry}"),
|
||||
});
|
||||
}
|
||||
Ok(Some(registry))
|
||||
}
|
||||
|
||||
fn normalize_optional_npm_source_field(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
value: Option<String>,
|
||||
field: &str,
|
||||
) -> Result<Option<String>, MarketplaceError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("npm plugin source {field} must not be empty"),
|
||||
});
|
||||
}
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
|
||||
fn normalize_github_git_url(url: &str) -> String {
|
||||
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
||||
format!("{url}.git")
|
||||
@@ -886,6 +1028,11 @@ enum RawMarketplaceManifestPluginSourceObject {
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
Npm {
|
||||
package: String,
|
||||
version: Option<String>,
|
||||
registry: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_marketplace_interface(
|
||||
|
||||
@@ -6,7 +6,6 @@ use tempfile::tempdir;
|
||||
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
|
||||
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
|
||||
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
|
||||
@@ -225,6 +224,248 @@ fn find_marketplace_plugin_omits_interface_asset_paths_for_git_sources() {
|
||||
assert!(interface.screenshots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_supports_npm_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "npm-plugin",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "^1.2.0",
|
||||
"registry": "https://npm.example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"npm-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("npm-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source: MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: Some("^1.2.0".to_string()),
|
||||
registry: Some("https://npm.example.com".to_string()),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
manifest: None,
|
||||
manifest_fallback: minimal_manifest_fallback("npm-plugin"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_skips_unsafe_npm_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
let marketplace_path = write_alternate_marketplace(
|
||||
&repo_root,
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "remote-version",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "https://attacker.example/plugin.tgz",
|
||||
"registry": "https://npm.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "local-version",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": ".",
|
||||
"registry": "https://npm.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "plaintext-registry",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "1.2.0",
|
||||
"registry": "http://npm.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "credential-registry",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "1.2.0",
|
||||
"registry": "https://user:password@npm.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dot-package",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": ".codex-plugin",
|
||||
"registry": "https://npm.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "underscore-package",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "_codex-plugin",
|
||||
"registry": "https://npm.example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
load_marketplace(&marketplace_path).unwrap().plugins,
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_supports_npm_registry_version_selectors() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
let marketplace_path = write_alternate_marketplace(
|
||||
&repo_root,
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "dist-tag",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "latest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "comparator-range",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": ">=1.2.7 <1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-range",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "1.2.x"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "or-range",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin",
|
||||
"version": "1.2.7 || >=1.2.9 <2.0.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
load_marketplace(&marketplace_path)
|
||||
.unwrap()
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.source)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: Some("latest".to_string()),
|
||||
registry: None,
|
||||
},
|
||||
MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: Some(">=1.2.7 <1.3.0".to_string()),
|
||||
registry: None,
|
||||
},
|
||||
MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: Some("1.2.x".to_string()),
|
||||
registry: None,
|
||||
},
|
||||
MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: Some("1.2.7 || >=1.2.9 <2.0.0".to_string()),
|
||||
registry: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_supports_npm_sources_without_optional_fields() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "npm-plugin",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@acme/codex-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"npm-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source,
|
||||
MarketplacePluginSource::Npm {
|
||||
package: "@acme/codex-plugin".to_string(),
|
||||
version: None,
|
||||
registry: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_builds_manifest_fallback_from_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
use crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const NPM_PLUGIN_SOURCE_STAGING_DIR: &str = "plugins/.marketplace-plugin-source-staging";
|
||||
const NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
const NPM_PLUGIN_SOURCE_MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024;
|
||||
const NPM_PACKAGE_ARCHIVE_ROOT: &str = "package";
|
||||
|
||||
pub(crate) fn materialize_npm_plugin_source(
|
||||
codex_home: &Path,
|
||||
package: &str,
|
||||
version: Option<&str>,
|
||||
registry: Option<&str>,
|
||||
) -> Result<(AbsolutePathBuf, TempDir), String> {
|
||||
materialize_npm_plugin_source_with_command(
|
||||
codex_home,
|
||||
package,
|
||||
version,
|
||||
registry,
|
||||
OsStr::new(npm_command()),
|
||||
)
|
||||
}
|
||||
|
||||
fn materialize_npm_plugin_source_with_command(
|
||||
codex_home: &Path,
|
||||
package: &str,
|
||||
version: Option<&str>,
|
||||
registry: Option<&str>,
|
||||
npm_command: &OsStr,
|
||||
) -> Result<(AbsolutePathBuf, TempDir), String> {
|
||||
let staging_root = codex_home.join(NPM_PLUGIN_SOURCE_STAGING_DIR);
|
||||
fs::create_dir_all(&staging_root).map_err(|err| {
|
||||
format!(
|
||||
"failed to create marketplace plugin source staging directory {}: {err}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let tempdir = tempfile::Builder::new()
|
||||
.prefix("marketplace-plugin-source-")
|
||||
.tempdir_in(&staging_root)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create marketplace plugin source staging directory in {}: {err}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
pack_npm_package(tempdir.path(), package, version, registry, npm_command)?;
|
||||
let archive_path = find_npm_package_archive(tempdir.path())?;
|
||||
let archive_bytes = read_npm_package_archive(&archive_path)?;
|
||||
|
||||
let extraction_root = tempdir.path().join("extracted");
|
||||
unpack_plugin_bundle_tar_gz(
|
||||
&archive_bytes,
|
||||
&extraction_root,
|
||||
NPM_PLUGIN_SOURCE_MAX_EXTRACTED_BYTES,
|
||||
)
|
||||
.map_err(|err| format!("failed to extract npm plugin package: {err}"))?;
|
||||
let plugin_root = extraction_root.join(NPM_PACKAGE_ARCHIVE_ROOT);
|
||||
if !plugin_root.is_dir() {
|
||||
return Err(format!(
|
||||
"npm pack completed without creating plugin package directory {}",
|
||||
plugin_root.display()
|
||||
));
|
||||
}
|
||||
validate_npm_package_metadata(&plugin_root, package)?;
|
||||
let plugin_root = AbsolutePathBuf::try_from(plugin_root)
|
||||
.map_err(|err| format!("failed to resolve materialized plugin source path: {err}"))?;
|
||||
Ok((plugin_root, tempdir))
|
||||
}
|
||||
|
||||
fn pack_npm_package(
|
||||
destination: &Path,
|
||||
package: &str,
|
||||
version: Option<&str>,
|
||||
registry: Option<&str>,
|
||||
npm_command: &OsStr,
|
||||
) -> Result<(), String> {
|
||||
let package_spec = version.map_or_else(
|
||||
|| package.to_string(),
|
||||
|version| format!("{package}@{version}"),
|
||||
);
|
||||
let mut command = Command::new(npm_command);
|
||||
command
|
||||
.current_dir(destination)
|
||||
.arg("pack")
|
||||
.arg("--ignore-scripts")
|
||||
.arg("--pack-destination")
|
||||
.arg(destination);
|
||||
if let Some(registry) = registry {
|
||||
command.arg("--registry").arg(registry);
|
||||
}
|
||||
command.arg("--").arg(package_spec);
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|err| format!("failed to run npm pack: {err}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"npm pack failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
fn find_npm_package_archive(destination: &Path) -> Result<PathBuf, String> {
|
||||
let mut archives = fs::read_dir(destination)
|
||||
.map_err(|err| format!("failed to read npm pack destination: {err}"))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let is_file = entry.file_type().is_ok_and(|file_type| file_type.is_file());
|
||||
(is_file && path.extension() == Some(OsStr::new("tgz"))).then_some(path)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if archives.len() != 1 {
|
||||
return Err(format!(
|
||||
"npm pack completed with {} package archives; expected exactly one",
|
||||
archives.len()
|
||||
));
|
||||
}
|
||||
Ok(archives.remove(0))
|
||||
}
|
||||
|
||||
fn read_npm_package_archive(archive_path: &Path) -> Result<Vec<u8>, String> {
|
||||
let archive_size = fs::metadata(archive_path)
|
||||
.map_err(|err| format!("failed to inspect npm package archive: {err}"))?
|
||||
.len();
|
||||
if archive_size > NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES {
|
||||
return Err(format!(
|
||||
"npm package archive is {archive_size} bytes, exceeding maximum size of {NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
fs::read(archive_path).map_err(|err| format!("failed to read npm package archive: {err}"))
|
||||
}
|
||||
|
||||
fn validate_npm_package_metadata(plugin_root: &Path, package: &str) -> Result<(), String> {
|
||||
#[derive(Deserialize)]
|
||||
struct NpmPackageMetadata {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let package_json_path = plugin_root.join("package.json");
|
||||
let package_json = fs::read_to_string(&package_json_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to read npm plugin package metadata {}: {err}",
|
||||
package_json_path.display()
|
||||
)
|
||||
})?;
|
||||
let metadata: NpmPackageMetadata = serde_json::from_str(&package_json).map_err(|err| {
|
||||
format!(
|
||||
"failed to parse npm plugin package metadata {}: {err}",
|
||||
package_json_path.display()
|
||||
)
|
||||
})?;
|
||||
if metadata.name != package {
|
||||
return Err(format!(
|
||||
"npm plugin package name '{}' does not match requested package '{package}'",
|
||||
metadata.name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn npm_command() -> &'static str {
|
||||
"npm.cmd"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn npm_command() -> &'static str {
|
||||
"npm"
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "npm_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::*;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn materialize_npm_plugin_source_uses_packed_package_root() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let fake_npm_dir = tempfile::tempdir().expect("create fake npm directory");
|
||||
let archive_bytes =
|
||||
npm_package_archive_bytes("@acme/plugin", "1.2.0").expect("build fixture archive");
|
||||
let archive_path = fake_npm_dir.path().join("fixture.tgz");
|
||||
fs::write(&archive_path, &archive_bytes).expect("write fixture archive");
|
||||
let fake_npm = fake_npm_dir.path().join("npm");
|
||||
fs::write(
|
||||
&fake_npm,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
destination=""
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = "--pack-destination" ]; then
|
||||
destination="$argument"
|
||||
fi
|
||||
previous="$argument"
|
||||
done
|
||||
cp "{}" "$destination/acme-plugin-1.2.0.tgz"
|
||||
printf '%s\n' "$@" > "$destination/args.txt"
|
||||
pwd > "$destination/pwd.txt"
|
||||
"#,
|
||||
archive_path.display()
|
||||
),
|
||||
)
|
||||
.expect("write fake npm");
|
||||
let mut permissions = fs::metadata(&fake_npm)
|
||||
.expect("read fake npm metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&fake_npm, permissions).expect("make fake npm executable");
|
||||
|
||||
let (plugin_root, tempdir) = materialize_npm_plugin_source_with_command(
|
||||
codex_home.path(),
|
||||
"@acme/plugin",
|
||||
Some("^1.2.0"),
|
||||
Some("https://npm.example.com"),
|
||||
fake_npm.as_os_str(),
|
||||
)
|
||||
.expect("materialize npm source");
|
||||
|
||||
assert_eq!(
|
||||
plugin_root.as_path(),
|
||||
tempdir.path().join("extracted/package")
|
||||
);
|
||||
assert!(
|
||||
plugin_root
|
||||
.as_path()
|
||||
.join(".codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
let args = fs::read_to_string(tempdir.path().join("args.txt")).expect("read npm arguments");
|
||||
assert!(args.contains("pack"));
|
||||
assert!(args.contains("--ignore-scripts"));
|
||||
assert!(args.contains("--registry"));
|
||||
assert!(args.contains("https://npm.example.com"));
|
||||
assert!(args.contains("@acme/plugin@^1.2.0"));
|
||||
assert!(!args.contains("install"));
|
||||
let npm_working_directory = fs::canonicalize(
|
||||
fs::read_to_string(tempdir.path().join("pwd.txt"))
|
||||
.expect("read npm working directory")
|
||||
.trim(),
|
||||
)
|
||||
.expect("canonicalize npm working directory");
|
||||
assert_eq!(
|
||||
npm_working_directory,
|
||||
fs::canonicalize(tempdir.path()).expect("canonicalize tempdir")
|
||||
);
|
||||
}
|
||||
|
||||
fn npm_package_archive_bytes(package: &str, version: &str) -> std::io::Result<Vec<u8>> {
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
append_archive_file(
|
||||
&mut archive,
|
||||
"package/package.json",
|
||||
format!(r#"{{"name":"{package}","version":"{version}"}}"#).as_bytes(),
|
||||
)?;
|
||||
append_archive_file(
|
||||
&mut archive,
|
||||
"package/.codex-plugin/plugin.json",
|
||||
br#"{"name":"plugin"}"#,
|
||||
)?;
|
||||
let encoder = archive.into_inner()?;
|
||||
encoder.finish()
|
||||
}
|
||||
|
||||
fn append_archive_file<W: Write>(
|
||||
archive: &mut tar::Builder<W>,
|
||||
path: &str,
|
||||
contents: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, Cursor::new(contents))
|
||||
}
|
||||
@@ -1474,6 +1474,12 @@ fn plugin_source_summary(plugin: &PluginDetail) -> String {
|
||||
Some(ref_name) => format!("Git · {url}@{ref_name}"),
|
||||
None => format!("Git · {url}"),
|
||||
},
|
||||
PluginSource::Npm {
|
||||
package, version, ..
|
||||
} => match version {
|
||||
Some(version) => format!("npm · {package}@{version}"),
|
||||
None => format!("npm · {package}"),
|
||||
},
|
||||
PluginSource::Remote => {
|
||||
let marketplace_label =
|
||||
MarketplaceProduct::from_marketplace_name(&plugin.marketplace_name)
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: strip_osc8_for_snapshot(&popup)
|
||||
---
|
||||
Plugins
|
||||
Figma · Can be installed · ChatGPT Marketplace
|
||||
Data shared with this app is subject to the app's terms of service and privacy policy. Learn
|
||||
more.
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
Source npm · @acme/figma-plugin@^1.2.0
|
||||
Auth Auth on install
|
||||
Skills design-review, extract-copy
|
||||
Hooks PreToolUse (1), Stop (2)
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
@@ -664,6 +664,51 @@ async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_snapshot_shows_npm_source() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let mut summary = plugins_test_summary(
|
||||
"plugin-figma",
|
||||
"figma",
|
||||
Some("Figma"),
|
||||
Some("Design handoff."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
);
|
||||
summary.source = PluginSource::Npm {
|
||||
package: "@acme/figma-plugin".to_string(),
|
||||
version: Some("^1.2.0".to_string()),
|
||||
registry: Some("https://npm.example.com".to_string()),
|
||||
};
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
let plugin = plugins_test_detail(
|
||||
summary,
|
||||
Some("Turn Figma files into implementation context."),
|
||||
&["design-review", "extract-copy"],
|
||||
&[
|
||||
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
|
||||
(codex_app_server_protocol::HookEventName::Stop, 2),
|
||||
],
|
||||
&["Figma", "Slack"],
|
||||
&["figma-mcp", "docs-mcp"],
|
||||
);
|
||||
chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin }));
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert_chatwidget_snapshot!(
|
||||
"plugin_detail_popup_npm_source",
|
||||
strip_osc8_for_snapshot(&popup)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_distinguishes_admin_installed_from_enabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user