[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 authored and GitHub committed 2026-06-26 17:24:46 -04:00
1 parent 526f495f3a
commit 6509f3148a
21 files changed
+1102 -33

No files matched your search

+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}."
)
}