[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:
charlesgong-openai
2026-06-26 17:24:46 -04:00
committed by GitHub
Unverified
parent 526f495f3a
commit 6509f3148a
21 changed files with 1102 additions and 33 deletions
+1
View File
@@ -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;
+17
View File
@@ -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),
})
}
}
}
+43 -25
View File
@@ -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,28 +1766,27 @@ impl PluginsManager {
});
}
let source_path =
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && plugin.installed {
self.store.active_plugin_root(&plugin_id).ok_or_else(|| {
MarketplaceError::InvalidPlugin(format!(
"installed plugin cache entry is missing for {plugin_key}"
))
})?
} else {
let codex_home = self.codex_home.clone();
let source = plugin.source.clone();
let materialized = tokio::task::spawn_blocking(move || {
materialize_marketplace_plugin_source(codex_home.as_path(), &source)
})
.await
.map_err(|err| {
MarketplaceError::InvalidPlugin(format!(
"failed to materialize plugin source: {err}"
))
})?
.map_err(MarketplaceError::InvalidPlugin)?;
materialized.path.clone()
};
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}"
))
})?
} else {
let codex_home = self.codex_home.clone();
let source = plugin.source.clone();
let materialized = tokio::task::spawn_blocking(move || {
materialize_marketplace_plugin_source(codex_home.as_path(), &source)
})
.await
.map_err(|err| {
MarketplaceError::InvalidPlugin(format!(
"failed to materialize plugin source: {err}"
))
})?
.map_err(MarketplaceError::InvalidPlugin)?;
materialized.path.clone()
};
if !source_path.as_path().is_dir() {
return Err(MarketplaceError::InvalidPlugin(
"path does not exist or is not a directory".to_string(),
@@ -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}."
)
}
+151 -4
View File
@@ -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(&registry).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(
+242 -1
View File
@@ -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();
+188
View File
@@ -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))
}