Files
codex/codex-rs/core-plugins/src/npm_source.rs
charlesgong-openai 6509f3148a [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.
2026-06-26 17:24:46 -04:00

189 lines
6.0 KiB
Rust

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;