From 048cae405c46b45d9bda1426fabb561276952c14 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 25 Jun 2026 14:01:34 -0400 Subject: [PATCH 1/4] Support npm marketplace plugin sources --- .../codex_app_server_protocol.schemas.json | 34 ++++ .../codex_app_server_protocol.v2.schemas.json | 34 ++++ .../json/v2/PluginInstalledResponse.json | 34 ++++ .../schema/json/v2/PluginListResponse.json | 34 ++++ .../schema/json/v2/PluginReadResponse.json | 34 ++++ .../json/v2/PluginShareListResponse.json | 34 ++++ .../schema/typescript/v2/PluginSource.ts | 10 +- .../src/protocol/v2/plugin.rs | 9 + .../src/protocol/v2/tests.rs | 17 +- codex-rs/app-server/README.md | 2 +- .../src/request_processors/plugins.rs | 11 +- codex-rs/cli/src/plugin_cmd.rs | 30 +++ codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/loader.rs | 17 ++ codex-rs/core-plugins/src/manager.rs | 68 ++++--- codex-rs/core-plugins/src/marketplace.rs | 138 ++++++++++++- .../core-plugins/src/marketplace_tests.rs | 140 ++++++++++++- codex-rs/core-plugins/src/npm_source.rs | 187 ++++++++++++++++++ codex-rs/core-plugins/src/npm_source_tests.rs | 100 ++++++++++ codex-rs/tui/src/chatwidget/plugin_catalog.rs | 6 + 20 files changed, 906 insertions(+), 34 deletions(-) create mode 100644 codex-rs/core-plugins/src/npm_source.rs create mode 100644 codex-rs/core-plugins/src/npm_source_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8625c08da4f6..ab6c52e23808 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14121,6 +14121,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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 94fb8a989037..893f8c3aecfc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10525,6 +10525,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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index ce4ae5aa0ba6..4c5b205538d8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 1002ad1a8de6..62b9ac19ea8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index a2d2d442c599..ad63129bf8e3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index be97e20b6512..7c6f48624080 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts index f6e867195d6f..c7ba3bcf1ccc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts @@ -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" }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index dbe0b3d55d4c..148fe2f2a4f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -744,6 +744,15 @@ pub enum PluginSource { ref_name: Option, sha: Option, }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Npm { + package: String, + /// Optional npm version or version range. + version: Option, + /// Optional HTTPS registry URL. Authentication stays in the user's npm config. + registry: Option, + }, /// The plugin is available in the remote catalog. Download metadata is /// kept server-side and is not exposed through the app-server API. Remote, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7c78fee4c83e..2e82314a8bad 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2834,7 +2834,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 { @@ -2868,6 +2868,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!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4c88d4b69d32..61c5427d1f75 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -212,7 +212,7 @@ Example with notification opt-out: - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Local, Git, and npm marketplace sources are surfaced through `summary.source`; npm sources support a package name plus optional version or version range and registry fields. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d123f0379c5d..020fdc7cbd2c 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -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, } } diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 8c9144dfff0a..6d1771997424 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -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, }, + Npm { + package: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + registry: Option, + }, } 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, + }, } } } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 7ee5ddd117ca..d2047afa8895 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -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; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 49f4580e3995..2f406439aef8 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -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), + }) + } } } diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b357c5b53643..39e5152abdf0 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -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}." ) } diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 3190e0449aa1..26732b02c736 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -7,6 +7,7 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; +use semver::VersionReq; use serde::Deserialize; use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; @@ -86,8 +87,8 @@ impl MarketplacePluginManifestFallback { } pub(crate) fn parse_for_listing(&self) -> Option { - // 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 +134,17 @@ pub enum MarketplacePluginSource { ref_name: Option, sha: Option, }, + Npm { + package: String, + version: Option, + registry: Option, + }, +} + +impl MarketplacePluginSource { + pub(crate) fn is_install_materialized(&self) -> bool { + matches!(self, Self::Git { .. } | Self::Npm { .. }) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -513,10 +525,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 +624,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 +773,106 @@ fn normalize_optional_git_selector(value: &Option) -> Option { .map(str::to_string) } +fn normalize_npm_package( + marketplace_path: &AbsolutePathBuf, + package: &str, +) -> Result { + let package = package.trim(); + let segments = if let Some(scoped_package) = package.strip_prefix('@') { + scoped_package.split('/').collect::>() + } else { + package.split('/').collect::>() + }; + let expected_segments = if package.starts_with('@') { 2 } else { 1 }; + if package.is_empty() + || segments.len() != expected_segments + || segments + .iter() + .any(|segment| !is_valid_npm_package_segment(segment)) + { + 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) -> bool { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +fn normalize_optional_npm_version( + marketplace_path: &AbsolutePathBuf, + version: Option, +) -> Result, MarketplaceError> { + let Some(version) = normalize_optional_npm_source_field(marketplace_path, version, "version")? + else { + return Ok(None); + }; + if VersionReq::parse(&version).is_err() { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: format!( + "npm plugin source version must be a semver version or range: {version}" + ), + }); + } + Ok(Some(version)) +} + +fn normalize_optional_npm_registry( + marketplace_path: &AbsolutePathBuf, + registry: Option, +) -> Result, 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, + field: &str, +) -> Result, 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 +1011,11 @@ enum RawMarketplaceManifestPluginSourceObject { ref_name: Option, sha: Option, }, + Npm { + package: String, + version: Option, + registry: Option, + }, } fn resolve_marketplace_interface( diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 3ea2c9d60697..934bcf9a0a3b 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -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,145 @@ 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": "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" + } + } + ] +}"#, + ); + + assert_eq!( + load_marketplace(&marketplace_path).unwrap().plugins, + Vec::new() + ); +} + +#[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(); diff --git a/codex-rs/core-plugins/src/npm_source.rs b/codex-rs/core-plugins/src/npm_source.rs new file mode 100644 index 000000000000..eb91dfe77b41 --- /dev/null +++ b/codex-rs/core-plugins/src/npm_source.rs @@ -0,0 +1,187 @@ +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 + .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 { + 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::>(); + 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, 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; diff --git a/codex-rs/core-plugins/src/npm_source_tests.rs b/codex-rs/core-plugins/src/npm_source_tests.rs new file mode 100644 index 000000000000..9a5fb0addfd2 --- /dev/null +++ b/codex-rs/core-plugins/src/npm_source_tests.rs @@ -0,0 +1,100 @@ +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" +"#, + 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")); +} + +fn npm_package_archive_bytes(package: &str, version: &str) -> std::io::Result> { + 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( + archive: &mut tar::Builder, + 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)) +} diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index cb92ea7d312e..40dfca0ce51f 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -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) From a2504d277179f7b18f52309e27e466d3cd50dd52 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 25 Jun 2026 16:30:42 -0400 Subject: [PATCH 2/4] Address npm marketplace review feedback --- MODULE.bazel.lock | 4 + codex-rs/Cargo.lock | 42 ++++++++++ codex-rs/Cargo.toml | 1 + codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/marketplace.rs | 26 ++++-- .../core-plugins/src/marketplace_tests.rs | 81 +++++++++++++++++++ codex-rs/core-plugins/src/npm_source.rs | 1 + codex-rs/core-plugins/src/npm_source_tests.rs | 11 +++ ...tests__plugin_detail_popup_npm_source.snap | 20 +++++ .../chatwidget/tests/popups_and_settings.rs | 45 +++++++++++ 10 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_npm_source.snap diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8ae5bef17c0c..977ab2ccbc12 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -717,6 +717,7 @@ "borsh_1.6.0": "{\"dependencies\":[{\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.1\"},{\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"~1.6.0\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2.1\"},{\"name\":\"hashbrown\",\"optional\":true,\"req\":\">=0.11, <0.16.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.29.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"de_strict_order\":[],\"default\":[\"std\"],\"derive\":[\"borsh-derive\"],\"rc\":[],\"std\":[],\"unstable__schema\":[\"derive\",\"borsh-derive/schema\"]}}", "bstr_1.12.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"dfa-search\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"ucd-parse\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"unicode-segmentation\",\"req\":\"^1.2.1\"}],\"features\":{\"alloc\":[\"memchr/alloc\",\"serde?/alloc\"],\"default\":[\"std\",\"unicode\"],\"serde\":[\"dep:serde\"],\"std\":[\"alloc\",\"memchr/std\",\"serde?/std\"],\"unicode\":[\"dep:regex-automata\"]}}", "bumpalo_3.19.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"blink-alloc\",\"req\":\"=0.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"=1.10.0\"},{\"kind\":\"dev\",\"name\":\"rayon-core\",\"req\":\"=1.12.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.171\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.197\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.115\"}],\"features\":{\"allocator_api\":[],\"bench_allocator_api\":[\"allocator_api\",\"blink-alloc/nightly\"],\"boxed\":[],\"collections\":[],\"default\":[],\"serde\":[\"dep:serde\"],\"std\":[]}}", + "bytecount_0.6.9": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"generic-simd\":[],\"html_report\":[],\"runtime-dispatch-simd\":[]}}", "bytemuck_1.25.0": "{\"dependencies\":[{\"name\":\"bytemuck_derive\",\"optional\":true,\"req\":\"^1.10.2\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.22\"}],\"features\":{\"aarch64_simd\":[],\"align_offset\":[],\"alloc_uninit\":[],\"avx512_simd\":[],\"const_zeroed\":[],\"derive\":[\"bytemuck_derive\"],\"extern_crate_alloc\":[],\"extern_crate_std\":[\"extern_crate_alloc\"],\"impl_core_error\":[],\"latest_stable_rust\":[\"aarch64_simd\",\"avx512_simd\",\"align_offset\",\"alloc_uninit\",\"const_zeroed\",\"derive\",\"impl_core_error\",\"min_const_generics\",\"must_cast\",\"must_cast_extra\",\"pod_saturating\",\"track_caller\",\"transparentwrapper_extra\",\"wasm_simd\",\"zeroable_atomics\",\"zeroable_maybe_uninit\",\"zeroable_unwind_fn\"],\"min_const_generics\":[],\"must_cast\":[],\"must_cast_extra\":[\"must_cast\"],\"nightly_docs\":[],\"nightly_float\":[],\"nightly_portable_simd\":[\"rustversion\"],\"nightly_stdsimd\":[],\"pod_saturating\":[],\"track_caller\":[],\"transparentwrapper_extra\":[],\"unsound_ptr_pod_impl\":[],\"wasm_simd\":[],\"zeroable_atomics\":[],\"zeroable_maybe_uninit\":[],\"zeroable_unwind_fn\":[]}}", "bytemuck_derive_1.10.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "byteorder-lite_0.1.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1233,6 +1234,8 @@ "memchr_2.8.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memmap2_0.9.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.151\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"owning_ref\",\"req\":\"^0.4.1\"},{\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", + "miette-derive_7.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", + "miette_7.6.0": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.69\"},{\"name\":\"backtrace-ext\",\"optional\":true,\"req\":\"^0.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indenter\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"miette-derive\",\"optional\":true,\"req\":\"=7.6.0\"},{\"name\":\"owo-colors\",\"optional\":true,\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.21\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.196\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.196\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.113\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.0\"},{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-hyperlinks\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-unicode\",\"optional\":true,\"req\":\"^3.0.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"name\":\"syntect\",\"optional\":true,\"req\":\"^5.1.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"unicode-linebreak\",\"unicode-width\"],\"name\":\"textwrap\",\"optional\":true,\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2.0.11\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\"},{\"name\":\"unicode-width\",\"req\":\"^0.1.11\"}],\"features\":{\"default\":[\"derive\"],\"derive\":[\"dep:miette-derive\"],\"fancy\":[\"fancy-no-backtrace\",\"dep:backtrace\",\"dep:backtrace-ext\"],\"fancy-base\":[\"dep:owo-colors\",\"dep:textwrap\"],\"fancy-no-backtrace\":[\"fancy-base\",\"dep:terminal_size\",\"dep:supports-hyperlinks\",\"dep:supports-color\",\"dep:supports-unicode\"],\"fancy-no-syscall\":[\"fancy-base\"],\"no-format-args-capture\":[],\"syntect-highlighter\":[\"fancy-no-backtrace\",\"dep:syntect\"]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", @@ -1250,6 +1253,7 @@ "nix_0.28.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.1.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", "nix_0.29.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.155\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", "nix_0.30.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.3\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[\"poll\"],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"syslog\":[],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", + "node-semver_2.2.0": "{\"dependencies\":[{\"name\":\"bytecount\",\"req\":\"^0.6.0\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"miette\",\"req\":\"^7.5.0\"},{\"name\":\"nom\",\"req\":\"^7.1.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"serde\",\"req\":\"^1.0.126\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.115\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.57\"},{\"name\":\"thiserror\",\"req\":\"^1.0.30\"}],\"features\":{\"__bench\":[\"dep:criterion\"]}}", "nom_7.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"minimal-lexical\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"docsrs\":[],\"std\":[\"alloc\",\"memchr/std\",\"minimal-lexical/std\"]}}", "nom_8.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"=1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"docsrs\":[],\"std\":[\"alloc\",\"memchr/std\"]}}", "nonempty_0.12.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bincode\",\"optional\":true,\"req\":\"^2.0.1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"bincode\":[\"dep:bincode\"],\"default\":[\"std\"],\"serialize\":[\"dep:serde\"],\"std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bc5953803ed7..39cca101fe8b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1439,6 +1439,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -2789,6 +2795,7 @@ dependencies = [ "dirs", "flate2", "libc", + "node-semver", "pretty_assertions", "regex", "reqwest 0.12.28", @@ -9133,6 +9140,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -9295,6 +9324,19 @@ dependencies = [ "libc", ] +[[package]] +name = "node-semver" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" +dependencies = [ + "bytecount", + "miette", + "nom 7.1.3", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ef17cb9ce5f1..cd1aa8d3f295 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -343,6 +343,7 @@ maplit = "1.0.2" memchr = "2.7.6" mime_guess = "2.0.5" multimap = "0.10.0" +node-semver = "2.2.0" notify = "8.2.0" nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } once_cell = "1.20.2" diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 81cfe2f89cc9..3a2f3d4d2868 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -36,6 +36,7 @@ codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } +node-semver = { workspace = true } reqwest = { workspace = true } regex = { workspace = true } semver = { workspace = true } diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 26732b02c736..75bada5c6553 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -7,7 +7,7 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; -use semver::VersionReq; +use node_semver::Range; use serde::Deserialize; use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; @@ -141,6 +141,12 @@ pub enum MarketplacePluginSource { }, } +#[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 { .. }) @@ -778,17 +784,25 @@ fn normalize_npm_package( package: &str, ) -> Result { 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::>() } else { package.split('/').collect::>() }; - let expected_segments = if package.starts_with('@') { 2 } else { 1 }; + 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)) + .any(|segment| !is_valid_npm_package_segment(segment, package_scope)) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), @@ -798,10 +812,12 @@ fn normalize_npm_package( Ok(package.to_string()) } -fn is_valid_npm_package_segment(segment: &str) -> bool { +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, '-' | '_' | '.')) @@ -815,7 +831,7 @@ fn normalize_optional_npm_version( else { return Ok(None); }; - if VersionReq::parse(&version).is_err() { + if Range::parse(&version).is_err() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!( diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 934bcf9a0a3b..0aa8e5ab9b27 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -313,6 +313,22 @@ fn find_marketplace_plugin_skips_unsafe_npm_sources() { "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" + } } ] }"#, @@ -324,6 +340,71 @@ fn find_marketplace_plugin_skips_unsafe_npm_sources() { ); } +#[test] +fn find_marketplace_plugin_supports_npm_semver_ranges() { + 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": "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![ + 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(); diff --git a/codex-rs/core-plugins/src/npm_source.rs b/codex-rs/core-plugins/src/npm_source.rs index eb91dfe77b41..3d728bd0f747 100644 --- a/codex-rs/core-plugins/src/npm_source.rs +++ b/codex-rs/core-plugins/src/npm_source.rs @@ -89,6 +89,7 @@ fn pack_npm_package( ); let mut command = Command::new(npm_command); command + .current_dir(destination) .arg("pack") .arg("--ignore-scripts") .arg("--pack-destination") diff --git a/codex-rs/core-plugins/src/npm_source_tests.rs b/codex-rs/core-plugins/src/npm_source_tests.rs index 9a5fb0addfd2..3c2dcd94ac5f 100644 --- a/codex-rs/core-plugins/src/npm_source_tests.rs +++ b/codex-rs/core-plugins/src/npm_source_tests.rs @@ -31,6 +31,7 @@ for argument in "$@"; do done cp "{}" "$destination/acme-plugin-1.2.0.tgz" printf '%s\n' "$@" > "$destination/args.txt" +pwd > "$destination/pwd.txt" "#, archive_path.display() ), @@ -68,6 +69,16 @@ printf '%s\n' "$@" > "$destination/args.txt" 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> { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_npm_source.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_npm_source.snap new file mode 100644 index 000000000000..cb0725371806 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_npm_source.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 7d162d41e46c..49b9ec7030ea 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -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; From b31aa792088f69ab887a76ed582ee5c716e000f6 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 25 Jun 2026 20:27:41 -0400 Subject: [PATCH 3/4] Remove npm semver parser dependency --- MODULE.bazel.lock | 4 -- codex-rs/Cargo.lock | 42 ------------------- codex-rs/Cargo.toml | 1 - codex-rs/core-plugins/Cargo.toml | 1 - codex-rs/core-plugins/src/marketplace.rs | 11 ++--- .../core-plugins/src/marketplace_tests.rs | 24 ++++++++++- 6 files changed, 29 insertions(+), 54 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 977ab2ccbc12..8ae5bef17c0c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -717,7 +717,6 @@ "borsh_1.6.0": "{\"dependencies\":[{\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.1\"},{\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"~1.6.0\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2.1\"},{\"name\":\"hashbrown\",\"optional\":true,\"req\":\">=0.11, <0.16.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.29.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"de_strict_order\":[],\"default\":[\"std\"],\"derive\":[\"borsh-derive\"],\"rc\":[],\"std\":[],\"unstable__schema\":[\"derive\",\"borsh-derive/schema\"]}}", "bstr_1.12.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"dfa-search\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"ucd-parse\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"unicode-segmentation\",\"req\":\"^1.2.1\"}],\"features\":{\"alloc\":[\"memchr/alloc\",\"serde?/alloc\"],\"default\":[\"std\",\"unicode\"],\"serde\":[\"dep:serde\"],\"std\":[\"alloc\",\"memchr/std\",\"serde?/std\"],\"unicode\":[\"dep:regex-automata\"]}}", "bumpalo_3.19.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.8\"},{\"kind\":\"dev\",\"name\":\"blink-alloc\",\"req\":\"=0.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"=1.10.0\"},{\"kind\":\"dev\",\"name\":\"rayon-core\",\"req\":\"=1.12.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.171\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.197\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.115\"}],\"features\":{\"allocator_api\":[],\"bench_allocator_api\":[\"allocator_api\",\"blink-alloc/nightly\"],\"boxed\":[],\"collections\":[],\"default\":[],\"serde\":[\"dep:serde\"],\"std\":[]}}", - "bytecount_0.6.9": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"generic-simd\":[],\"html_report\":[],\"runtime-dispatch-simd\":[]}}", "bytemuck_1.25.0": "{\"dependencies\":[{\"name\":\"bytemuck_derive\",\"optional\":true,\"req\":\"^1.10.2\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.22\"}],\"features\":{\"aarch64_simd\":[],\"align_offset\":[],\"alloc_uninit\":[],\"avx512_simd\":[],\"const_zeroed\":[],\"derive\":[\"bytemuck_derive\"],\"extern_crate_alloc\":[],\"extern_crate_std\":[\"extern_crate_alloc\"],\"impl_core_error\":[],\"latest_stable_rust\":[\"aarch64_simd\",\"avx512_simd\",\"align_offset\",\"alloc_uninit\",\"const_zeroed\",\"derive\",\"impl_core_error\",\"min_const_generics\",\"must_cast\",\"must_cast_extra\",\"pod_saturating\",\"track_caller\",\"transparentwrapper_extra\",\"wasm_simd\",\"zeroable_atomics\",\"zeroable_maybe_uninit\",\"zeroable_unwind_fn\"],\"min_const_generics\":[],\"must_cast\":[],\"must_cast_extra\":[\"must_cast\"],\"nightly_docs\":[],\"nightly_float\":[],\"nightly_portable_simd\":[\"rustversion\"],\"nightly_stdsimd\":[],\"pod_saturating\":[],\"track_caller\":[],\"transparentwrapper_extra\":[],\"unsound_ptr_pod_impl\":[],\"wasm_simd\":[],\"zeroable_atomics\":[],\"zeroable_maybe_uninit\":[],\"zeroable_unwind_fn\":[]}}", "bytemuck_derive_1.10.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "byteorder-lite_0.1.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1234,8 +1233,6 @@ "memchr_2.8.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memmap2_0.9.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.151\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"owning_ref\",\"req\":\"^0.4.1\"},{\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", - "miette-derive_7.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", - "miette_7.6.0": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.69\"},{\"name\":\"backtrace-ext\",\"optional\":true,\"req\":\"^0.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indenter\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"miette-derive\",\"optional\":true,\"req\":\"=7.6.0\"},{\"name\":\"owo-colors\",\"optional\":true,\"req\":\"^4.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.21\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.196\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.196\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.113\"},{\"kind\":\"dev\",\"name\":\"strip-ansi-escapes\",\"req\":\"^0.2.0\"},{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-hyperlinks\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-unicode\",\"optional\":true,\"req\":\"^3.0.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"name\":\"syntect\",\"optional\":true,\"req\":\"^5.1.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"unicode-linebreak\",\"unicode-width\"],\"name\":\"textwrap\",\"optional\":true,\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2.0.11\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\"},{\"name\":\"unicode-width\",\"req\":\"^0.1.11\"}],\"features\":{\"default\":[\"derive\"],\"derive\":[\"dep:miette-derive\"],\"fancy\":[\"fancy-no-backtrace\",\"dep:backtrace\",\"dep:backtrace-ext\"],\"fancy-base\":[\"dep:owo-colors\",\"dep:textwrap\"],\"fancy-no-backtrace\":[\"fancy-base\",\"dep:terminal_size\",\"dep:supports-hyperlinks\",\"dep:supports-color\",\"dep:supports-unicode\"],\"fancy-no-syscall\":[\"fancy-base\"],\"no-format-args-capture\":[],\"syntect-highlighter\":[\"fancy-no-backtrace\",\"dep:syntect\"]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", @@ -1253,7 +1250,6 @@ "nix_0.28.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.1.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", "nix_0.29.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.155\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", "nix_0.30.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert-impl\",\"req\":\"^0.1\"},{\"name\":\"bitflags\",\"req\":\"^2.3.3\"},{\"kind\":\"dev\",\"name\":\"caps\",\"req\":\"^0.5.3\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2.1\"},{\"features\":[\"extra_traits\"],\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"sysctl\",\"req\":\"^0.4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{\"acct\":[],\"aio\":[\"pin-utils\"],\"default\":[],\"dir\":[\"fs\"],\"env\":[],\"event\":[\"poll\"],\"fanotify\":[],\"feature\":[],\"fs\":[],\"hostname\":[],\"inotify\":[],\"ioctl\":[],\"kmod\":[],\"mman\":[],\"mount\":[\"uio\"],\"mqueue\":[\"fs\"],\"net\":[\"socket\"],\"personality\":[],\"poll\":[],\"process\":[],\"pthread\":[],\"ptrace\":[\"process\"],\"quota\":[],\"reboot\":[],\"resource\":[],\"sched\":[\"process\"],\"signal\":[\"process\"],\"socket\":[\"memoffset\"],\"syslog\":[],\"term\":[],\"time\":[],\"ucontext\":[\"signal\"],\"uio\":[],\"user\":[\"feature\"],\"zerocopy\":[\"fs\",\"uio\"]}}", - "node-semver_2.2.0": "{\"dependencies\":[{\"name\":\"bytecount\",\"req\":\"^0.6.0\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"miette\",\"req\":\"^7.5.0\"},{\"name\":\"nom\",\"req\":\"^7.1.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"serde\",\"req\":\"^1.0.126\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.115\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.57\"},{\"name\":\"thiserror\",\"req\":\"^1.0.30\"}],\"features\":{\"__bench\":[\"dep:criterion\"]}}", "nom_7.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"minimal-lexical\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"docsrs\":[],\"std\":[\"alloc\",\"memchr/std\",\"minimal-lexical/std\"]}}", "nom_8.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"=1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"docsrs\":[],\"std\":[\"alloc\",\"memchr/std\"]}}", "nonempty_0.12.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bincode\",\"optional\":true,\"req\":\"^2.0.1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"bincode\":[\"dep:bincode\"],\"default\":[\"std\"],\"serialize\":[\"dep:serde\"],\"std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 39cca101fe8b..bc5953803ed7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1439,12 +1439,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytemuck" version = "1.25.0" @@ -2795,7 +2789,6 @@ dependencies = [ "dirs", "flate2", "libc", - "node-semver", "pretty_assertions", "regex", "reqwest 0.12.28", @@ -9140,28 +9133,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "cfg-if", - "miette-derive", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "mime" version = "0.3.17" @@ -9324,19 +9295,6 @@ dependencies = [ "libc", ] -[[package]] -name = "node-semver" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" -dependencies = [ - "bytecount", - "miette", - "nom 7.1.3", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "nom" version = "7.1.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cd1aa8d3f295..ef17cb9ce5f1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -343,7 +343,6 @@ maplit = "1.0.2" memchr = "2.7.6" mime_guess = "2.0.5" multimap = "0.10.0" -node-semver = "2.2.0" notify = "8.2.0" nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } once_cell = "1.20.2" diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 3a2f3d4d2868..81cfe2f89cc9 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -36,7 +36,6 @@ codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } -node-semver = { workspace = true } reqwest = { workspace = true } regex = { workspace = true } semver = { workspace = true } diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 75bada5c6553..6475d3787600 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -7,7 +7,6 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; -use node_semver::Range; use serde::Deserialize; use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; @@ -831,17 +830,19 @@ fn normalize_optional_npm_version( else { return Ok(None); }; - if Range::parse(&version).is_err() { + if !is_registry_npm_version_selector(&version) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), - message: format!( - "npm plugin source version must be a semver version or range: {version}" - ), + 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, diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 0aa8e5ab9b27..d30e9ad1dacd 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -296,6 +296,15 @@ fn find_marketplace_plugin_skips_unsafe_npm_sources() { "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": { @@ -341,7 +350,7 @@ fn find_marketplace_plugin_skips_unsafe_npm_sources() { } #[test] -fn find_marketplace_plugin_supports_npm_semver_ranges() { +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(); @@ -350,6 +359,14 @@ fn find_marketplace_plugin_supports_npm_semver_ranges() { r#"{ "name": "codex-curated", "plugins": [ + { + "name": "dist-tag", + "source": { + "source": "npm", + "package": "@acme/codex-plugin", + "version": "latest" + } + }, { "name": "comparator-range", "source": { @@ -386,6 +403,11 @@ fn find_marketplace_plugin_supports_npm_semver_ranges() { .map(|plugin| plugin.source) .collect::>(), 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()), From 38af531909c9b07a30cc6e35d037b6f0c9f70834 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Fri, 26 Jun 2026 07:27:20 -0400 Subject: [PATCH 4/4] Remove npm marketplace details from app-server docs --- codex-rs/app-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 61c5427d1f75..4c88d4b69d32 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -212,7 +212,7 @@ Example with notification opt-out: - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Local, Git, and npm marketplace sources are surfaced through `summary.source`; npm sources support a package name plus optional version or version range and registry fields. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.