[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
@@ -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": {
@@ -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,
}
}
+30
View File
@@ -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,
},
}
}
}
+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))
}
@@ -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)
@@ -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;