mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[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:
1 parent
526f495f3a
commit
6509f3148a
21 files changed
+1102
-33
No files matched your search
@@ -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(®istry).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(
|
||||
|
||||
Reference in new issue
Block a user