Files
codex/codex-rs/core-plugins/src/npm_source_tests.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

112 lines
3.5 KiB
Rust

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))
}