mirror of
https://github.com/openai/codex.git
synced 2026-08-29 14:09:35 +00:00
## 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.
189 lines
6.0 KiB
Rust
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;
|