From c6a8b84a78c0a74424e33f15d31c55f3f5cf3df1 Mon Sep 17 00:00:00 2001 From: roniz <35386615+chkp-roniz@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:18:07 +0300 Subject: [PATCH 1/3] feat: proxy-aware marketplace indexes (#506) Route marketplace browse/search/add through the registry proxy when PROXY_REGISTRY_URL is set. When PROXY_REGISTRY_ONLY=1 the GitHub Contents API fallback is blocked entirely, enabling air-gapped marketplace discovery. Closes #506 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 + src/apm_cli/commands/marketplace.py | 45 ++++-- src/apm_cli/marketplace/client.py | 93 +++++++++++- .../marketplace/test_marketplace_client.py | 139 +++++++++++++++++- 4 files changed, 263 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce029b44..e53469dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Marketplace browse/search/add now route through registry proxy when PROXY_REGISTRY_URL is set; PROXY_REGISTRY_ONLY=1 blocks direct GitHub API calls (#506) + ## [0.8.11] - 2026-04-06 ### Added diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace.py index a7c39d03..a3567301 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace.py @@ -31,17 +31,17 @@ def marketplace(): @click.argument("repo", required=True) @click.option("--name", "-n", default=None, help="Display name (defaults to repo name)") @click.option("--branch", "-b", default="main", show_default=True, help="Branch to use") +@click.option("--host", default=None, help="Git host FQDN (default: github.com)") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def add(repo, name, branch, verbose): - """Register a marketplace from OWNER/REPO.""" +def add(repo, name, branch, host, verbose): + """Register a marketplace from OWNER/REPO or HOST/OWNER/REPO.""" logger = CommandLogger("marketplace-add", verbose=verbose) try: from ..marketplace.client import _auto_detect_path, fetch_marketplace from ..marketplace.models import MarketplaceSource from ..marketplace.registry import add_marketplace - from ..utils.github_host import default_host - # Parse OWNER/REPO + # Parse OWNER/REPO or HOST/OWNER/REPO if "/" not in repo: logger.error( f"Invalid format: '{repo}'. Use 'OWNER/REPO' " @@ -50,11 +50,31 @@ def add(repo, name, branch, verbose): sys.exit(1) parts = repo.split("/") - if len(parts) != 2 or not parts[0] or not parts[1]: + if len(parts) == 3 and parts[0] and parts[1] and parts[2]: + from ..utils.github_host import is_valid_fqdn + + if not is_valid_fqdn(parts[0]): + logger.error( + f"Invalid host: '{parts[0]}'. " + f"Use 'OWNER/REPO' or 'HOST/OWNER/REPO' format." + ) + sys.exit(1) + if host and host != parts[0]: + logger.error( + f"Conflicting host: --host '{host}' vs '{parts[0]}' in argument." + ) + sys.exit(1) + host = parts[0] + owner, repo_name = parts[1], parts[2] + elif len(parts) == 2 and parts[0] and parts[1]: + owner, repo_name = parts[0], parts[1] + else: logger.error(f"Invalid format: '{repo}'. Expected 'OWNER/REPO'") sys.exit(1) - owner, repo_name = parts[0], parts[1] + from ..utils.github_host import default_host + + resolved_host = host or default_host() display_name = name or repo_name # Validate name is identifier-compatible for NAME@MARKETPLACE syntax @@ -71,15 +91,16 @@ def add(repo, name, branch, verbose): logger.start(f"Registering marketplace '{display_name}'...", symbol="gear") logger.verbose_detail(f" Repository: {owner}/{repo_name}") logger.verbose_detail(f" Branch: {branch}") + if resolved_host != "github.com": + logger.verbose_detail(f" Host: {resolved_host}") # Auto-detect marketplace.json location - host = default_host() probe_source = MarketplaceSource( name=display_name, owner=owner, repo=repo_name, branch=branch, - host=host, + host=resolved_host, ) detected_path = _auto_detect_path(probe_source) @@ -99,7 +120,7 @@ def add(repo, name, branch, verbose): owner=owner, repo=repo_name, branch=branch, - host=host, + host=resolved_host, path=detected_path, ) @@ -270,7 +291,7 @@ def update(name, verbose): if name: source = get_marketplace_by_name(name) logger.start(f"Refreshing marketplace '{name}'...", symbol="gear") - clear_marketplace_cache(name) + clear_marketplace_cache(name, host=source.host) manifest = fetch_marketplace(source, force_refresh=True) logger.success( f"Marketplace '{name}' updated ({len(manifest.plugins)} plugins)", @@ -288,7 +309,7 @@ def update(name, verbose): ) for s in sources: try: - clear_marketplace_cache(s.name) + clear_marketplace_cache(s.name, host=s.host) manifest = fetch_marketplace(s, force_refresh=True) logger.tree_item( f" {s.name} ({len(manifest.plugins)} plugins)" @@ -331,7 +352,7 @@ def remove(name, yes, verbose): return remove_marketplace(name) - clear_marketplace_cache(name) + clear_marketplace_cache(name, host=source.host) logger.success(f"Marketplace '{name}' removed", symbol="check") except Exception as e: diff --git a/src/apm_cli/marketplace/client.py b/src/apm_cli/marketplace/client.py index cee2d6eb..2fa05b4f 100644 --- a/src/apm_cli/marketplace/client.py +++ b/src/apm_cli/marketplace/client.py @@ -2,6 +2,10 @@ Uses ``AuthResolver.try_with_fallback(unauth_first=True)`` for public-first access with automatic credential fallback for private marketplace repos. +When ``PROXY_REGISTRY_URL`` is set, fetches are routed through the registry +proxy (Artifactory Archive Entry Download) before falling back to the +GitHub Contents API. When ``PROXY_REGISTRY_ONLY=1``, the GitHub fallback +is blocked entirely. Cache lives at ``~/.apm/cache/marketplace/`` with a 1-hour TTL. """ @@ -56,6 +60,13 @@ def _sanitize_cache_name(name: str) -> str: return safe +def _cache_key(source: MarketplaceSource) -> str: + """Cache key that includes host to avoid collisions across hosts.""" + if source.host == "github.com": + return source.name + return f"{_sanitize_cache_name(source.host)}__{source.name}" + + def _cache_data_path(name: str) -> str: return os.path.join(_cache_dir(), f"{_sanitize_cache_name(name)}.json") @@ -126,6 +137,46 @@ def _clear_cache(name: str) -> None: # --------------------------------------------------------------------------- +def _try_proxy_fetch( + source: MarketplaceSource, + file_path: str, +) -> Optional[Dict]: + """Try to fetch marketplace JSON via the registry proxy. + + Returns parsed JSON dict on success, ``None`` when no proxy is + configured or the entry download fails. + """ + from ..deps.registry_proxy import RegistryConfig + + cfg = RegistryConfig.from_env() + if cfg is None: + return None + + from ..deps.artifactory_entry import fetch_entry_from_archive + + content = fetch_entry_from_archive( + host=cfg.host, + prefix=cfg.prefix, + owner=source.owner, + repo=source.repo, + file_path=file_path, + ref=source.branch, + scheme=cfg.scheme, + headers=cfg.get_headers(), + ) + if content is None: + return None + + try: + return json.loads(content) + except (json.JSONDecodeError, ValueError): + logger.debug( + "Proxy returned non-JSON for %s/%s %s", + source.owner, source.repo, file_path, + ) + return None + + def _github_contents_url(source: MarketplaceSource, file_path: str) -> str: """Build the GitHub Contents API URL for a file.""" from ..core.auth import AuthResolver @@ -140,11 +191,32 @@ def _fetch_file( file_path: str, auth_resolver: Optional[object] = None, ) -> Optional[Dict]: - """Fetch a JSON file from a GitHub repo via the Contents API. + """Fetch a JSON file from a GitHub repo. + + When ``PROXY_REGISTRY_URL`` is set, tries the registry proxy first via + Artifactory Archive Entry Download. Falls back to the GitHub Contents + API unless ``PROXY_REGISTRY_ONLY=1`` blocks direct access. Returns parsed JSON or ``None`` if the file does not exist (404). Raises ``MarketplaceFetchError`` on unexpected failures. """ + # Proxy-first: try Artifactory Archive Entry Download + proxy_result = _try_proxy_fetch(source, file_path) + if proxy_result is not None: + return proxy_result + + # When registry-only mode is active, block direct GitHub API access + from ..deps.registry_proxy import RegistryConfig + + cfg = RegistryConfig.from_env() + if cfg is not None and cfg.enforce_only: + logger.debug( + "PROXY_REGISTRY_ONLY blocks direct GitHub fetch for %s/%s %s", + source.owner, source.repo, file_path, + ) + return None + + # Fallback: GitHub Contents API url = _github_contents_url(source, file_path) def _do_fetch(token, _git_env): @@ -219,9 +291,11 @@ def fetch_marketplace( Raises: MarketplaceFetchError: If fetch fails and no cache is available. """ + cache_name = _cache_key(source) + # Try fresh cache first if not force_refresh: - cached = _read_cache(source.name) + cached = _read_cache(cache_name) if cached is not None: logger.debug("Using cached marketplace data for '%s'", source.name) return parse_marketplace_json(cached, source.name) @@ -235,11 +309,11 @@ def fetch_marketplace( f"marketplace.json not found at '{source.path}' " f"in {source.owner}/{source.repo}", ) - _write_cache(source.name, data) + _write_cache(cache_name, data) return parse_marketplace_json(data, source.name) except MarketplaceFetchError: # Stale-while-revalidate: serve expired cache on network error - stale = _read_stale_cache(source.name) + stale = _read_stale_cache(cache_name) if stale is not None: logger.warning( "Network error fetching '%s'; using stale cache", source.name @@ -287,16 +361,21 @@ def search_all_marketplaces( return results -def clear_marketplace_cache(name: Optional[str] = None) -> int: +def clear_marketplace_cache( + name: Optional[str] = None, + host: str = "github.com", +) -> int: """Clear cached data for one or all marketplaces. Returns the number of caches cleared. """ if name: - _clear_cache(name) + # Build a minimal source to derive the cache key + _src = MarketplaceSource(name=name, owner="", repo="", host=host) + _clear_cache(_cache_key(_src)) return 1 count = 0 for source in get_registered_marketplaces(): - _clear_cache(source.name) + _clear_cache(_cache_key(source)) count += 1 return count diff --git a/tests/unit/marketplace/test_marketplace_client.py b/tests/unit/marketplace/test_marketplace_client.py index 398d76cb..2cef3274 100644 --- a/tests/unit/marketplace/test_marketplace_client.py +++ b/tests/unit/marketplace/test_marketplace_client.py @@ -1,4 +1,4 @@ -"""Tests for marketplace client -- HTTP mock, caching, TTL, auth, auto-detection.""" +"""Tests for marketplace client -- HTTP mock, caching, TTL, auth, auto-detection, proxy.""" import json import time @@ -196,3 +196,140 @@ def test_not_found_anywhere(self, tmp_path): path = client_mod._auto_detect_path(source, auth_resolver=mock_resolver) assert path is None + + +class TestProxyAwareFetch: + """Proxy-aware marketplace fetch via Artifactory Archive Entry Download.""" + + _MARKETPLACE_JSON = {"name": "Test", "plugins": [{"name": "p1", "repository": "o/r"}]} + + def _make_cfg(self, enforce_only=False): + cfg = MagicMock() + cfg.host = "art.example.com" + cfg.prefix = "artifactory/github" + cfg.scheme = "https" + cfg.enforce_only = enforce_only + cfg.get_headers.return_value = {"Authorization": "Bearer tok"} + return cfg + + def test_proxy_fetch_success(self): + """Proxy returns valid JSON -- GitHub API is never called.""" + source = _make_source() + cfg = self._make_cfg() + raw = json.dumps(self._MARKETPLACE_JSON).encode() + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", return_value=raw) as mock_fetch: + result = client_mod._fetch_file(source, "marketplace.json") + + assert result == self._MARKETPLACE_JSON + mock_fetch.assert_called_once_with( + host="art.example.com", + prefix="artifactory/github", + owner="acme-org", + repo="plugins", + file_path="marketplace.json", + ref="main", + scheme="https", + headers={"Authorization": "Bearer tok"}, + ) + + def test_proxy_none_falls_through_to_github(self): + """Proxy returns None, no enforce_only -- falls through to GitHub API.""" + source = _make_source() + cfg = self._make_cfg(enforce_only=False) + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", return_value=None): + mock_resolver = MagicMock() + mock_resolver.try_with_fallback.return_value = self._MARKETPLACE_JSON + mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com") + result = client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver) + + assert result == self._MARKETPLACE_JSON + mock_resolver.try_with_fallback.assert_called_once() + + def test_proxy_only_blocks_github_fallback(self): + """Proxy returns None + enforce_only -- returns None, no GitHub call.""" + source = _make_source() + cfg = self._make_cfg(enforce_only=True) + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", return_value=None): + mock_resolver = MagicMock() + result = client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver) + + assert result is None + mock_resolver.try_with_fallback.assert_not_called() + + def test_no_proxy_uses_github(self): + """No proxy configured -- standard GitHub API path.""" + source = _make_source() + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None): + mock_resolver = MagicMock() + mock_resolver.try_with_fallback.return_value = self._MARKETPLACE_JSON + mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com") + result = client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver) + + assert result == self._MARKETPLACE_JSON + + def test_proxy_non_json_falls_through(self): + """Proxy returns non-JSON bytes -- treated as failure, falls to GitHub.""" + source = _make_source() + cfg = self._make_cfg(enforce_only=False) + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", return_value=b"\x89PNG binary"): + mock_resolver = MagicMock() + mock_resolver.try_with_fallback.return_value = self._MARKETPLACE_JSON + mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com") + result = client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver) + + assert result == self._MARKETPLACE_JSON + + def test_auto_detect_through_proxy(self): + """Auto-detect probes candidate paths through the proxy.""" + source = _make_source() + cfg = self._make_cfg() + call_count = [0] + + def mock_entry(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= 1: + return None # first candidate not found + return json.dumps(self._MARKETPLACE_JSON).encode() + + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", side_effect=mock_entry): + path = client_mod._auto_detect_path(source) + + assert path == ".github/plugin/marketplace.json" + + def test_fetch_marketplace_via_proxy_end_to_end(self): + """Full fetch_marketplace through proxy -- parses manifest correctly.""" + source = _make_source() + cfg = self._make_cfg() + raw = json.dumps(self._MARKETPLACE_JSON).encode() + with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=cfg), \ + patch("apm_cli.deps.artifactory_entry.fetch_entry_from_archive", return_value=raw): + manifest = client_mod.fetch_marketplace(source, force_refresh=True) + + assert manifest.name == "Test" + assert len(manifest.plugins) == 1 + assert manifest.plugins[0].name == "p1" + + +class TestCacheKey: + """Cache key includes host for non-github.com sources.""" + + def test_github_default_unchanged(self): + source = MarketplaceSource(name="skills", owner="o", repo="r") + assert client_mod._cache_key(source) == "skills" + + def test_non_default_host_includes_host(self): + source = MarketplaceSource(name="skills", owner="o", repo="r", host="ghes.corp.com") + key = client_mod._cache_key(source) + assert "ghes.corp.com" in key + assert "skills" in key + assert key != "skills" + + def test_different_hosts_different_keys(self): + s1 = MarketplaceSource(name="mkt", owner="o", repo="r", host="a.com") + s2 = MarketplaceSource(name="mkt", owner="o", repo="r", host="b.com") + assert client_mod._cache_key(s1) != client_mod._cache_key(s2) From f393409a2690ec21ddb09f83a1f726e5128d5170 Mon Sep 17 00:00:00 2001 From: roniz <35386615+chkp-roniz@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:38:14 +0300 Subject: [PATCH 2/3] fix: address Copilot review on #617 - Validate --host flag with is_valid_fqdn() and normalize to lowercase - Normalize host in _cache_key() for case-insensitive comparison - Update CHANGELOG to use backticks and include update command - Add docs for --host flag and proxy support in marketplace guide, CLI reference, and APM guide skill Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- docs/src/content/docs/guides/marketplaces.md | 19 +++++++++++++++++++ .../content/docs/reference/cli-commands.md | 7 +++++++ .../.apm/skills/apm-usage/commands.md | 2 +- src/apm_cli/commands/marketplace.py | 18 +++++++++++++----- src/apm_cli/marketplace/client.py | 5 +++-- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53469dd..de731717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Marketplace browse/search/add now route through registry proxy when PROXY_REGISTRY_URL is set; PROXY_REGISTRY_ONLY=1 blocks direct GitHub API calls (#506) +- `apm marketplace browse/search/add/update` now route through the registry proxy when `PROXY_REGISTRY_URL` is set; `PROXY_REGISTRY_ONLY=1` blocks direct GitHub API calls (#506) ## [0.8.11] - 2026-04-06 diff --git a/docs/src/content/docs/guides/marketplaces.md b/docs/src/content/docs/guides/marketplaces.md index b969022c..a2607822 100644 --- a/docs/src/content/docs/guides/marketplaces.md +++ b/docs/src/content/docs/guides/marketplaces.md @@ -74,10 +74,15 @@ This registers the marketplace and fetches its `marketplace.json`. By default AP **Options:** - `--name/-n` -- Custom display name for the marketplace - `--branch/-b` -- Branch to track (default: `main`) +- `--host` -- Git host FQDN for non-github.com hosts (default: `github.com` or `GITHUB_HOST` env var) ```bash # Register with a custom name on a specific branch apm marketplace add acme/plugin-marketplace --name acme-plugins --branch release + +# Register from a GitHub Enterprise host (two equivalent forms) +apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com +apm marketplace add ghes.corp.example.com/acme/plugin-marketplace ``` ## List registered marketplaces @@ -156,6 +161,20 @@ apm marketplace update acme-plugins apm marketplace update ``` +## Registry proxy support + +When `PROXY_REGISTRY_URL` is set, marketplace commands (`add`, `browse`, `search`, `update`) fetch `marketplace.json` through the registry proxy (Artifactory Archive Entry Download) before falling back to the GitHub Contents API. When `PROXY_REGISTRY_ONLY=1` is also set, the GitHub API fallback is blocked entirely, enabling fully air-gapped marketplace discovery. + +```bash +export PROXY_REGISTRY_URL="https://art.corp.example.com/artifactory/github" +export PROXY_REGISTRY_ONLY=1 # optional: block direct GitHub access + +apm marketplace add anthropics/skills # fetches via Artifactory +apm marketplace browse skills # fetches via Artifactory +``` + +This builds on the same proxy infrastructure used by `apm install`. See the [Registry Proxy guide](../registry-proxy/) for full configuration details. + ## Manage marketplaces Remove a registered marketplace: diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 24dc09dc..5ea793aa 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -872,14 +872,17 @@ Register a GitHub repository as a plugin marketplace. ```bash apm marketplace add OWNER/REPO [OPTIONS] +apm marketplace add HOST/OWNER/REPO [OPTIONS] ``` **Arguments:** - `OWNER/REPO` - GitHub repository containing `marketplace.json` +- `HOST/OWNER/REPO` - Repository on a non-github.com host (e.g., GitHub Enterprise) **Options:** - `-n, --name TEXT` - Custom display name for the marketplace - `-b, --branch TEXT` - Branch to track (default: main) +- `--host TEXT` - Git host FQDN (default: github.com or `GITHUB_HOST` env var) - `-v, --verbose` - Show detailed output **Examples:** @@ -889,6 +892,10 @@ apm marketplace add acme/plugin-marketplace # Register with a custom name and branch apm marketplace add acme/plugin-marketplace --name acme-plugins --branch release + +# Register from a GitHub Enterprise host +apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com +apm marketplace add ghes.corp.example.com/acme/plugin-marketplace ``` #### `apm marketplace list` - List registered marketplaces diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index c5a271d6..7bcf6970 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -50,7 +50,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm marketplace add OWNER/REPO` | Register a marketplace | `-n NAME`, `-b BRANCH` | +| `apm marketplace add OWNER/REPO` | Register a marketplace | `-n NAME`, `-b BRANCH`, `--host HOST` | | `apm marketplace list` | List registered marketplaces | -- | | `apm marketplace browse NAME` | Browse marketplace packages | -- | | `apm marketplace update [NAME]` | Update marketplace index | -- | diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace.py index a3567301..ef4e201d 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace.py @@ -49,10 +49,10 @@ def add(repo, name, branch, host, verbose): ) sys.exit(1) + from ..utils.github_host import default_host, is_valid_fqdn + parts = repo.split("/") if len(parts) == 3 and parts[0] and parts[1] and parts[2]: - from ..utils.github_host import is_valid_fqdn - if not is_valid_fqdn(parts[0]): logger.error( f"Invalid host: '{parts[0]}'. " @@ -72,9 +72,17 @@ def add(repo, name, branch, host, verbose): logger.error(f"Invalid format: '{repo}'. Expected 'OWNER/REPO'") sys.exit(1) - from ..utils.github_host import default_host - - resolved_host = host or default_host() + if host is not None: + normalized_host = host.strip().lower() + if not is_valid_fqdn(normalized_host): + logger.error( + f"Invalid host: '{host}'. Expected a valid host FQDN " + f"(for example, 'github.com')." + ) + sys.exit(1) + resolved_host = normalized_host + else: + resolved_host = default_host() display_name = name or repo_name # Validate name is identifier-compatible for NAME@MARKETPLACE syntax diff --git a/src/apm_cli/marketplace/client.py b/src/apm_cli/marketplace/client.py index 2fa05b4f..76e1c351 100644 --- a/src/apm_cli/marketplace/client.py +++ b/src/apm_cli/marketplace/client.py @@ -62,9 +62,10 @@ def _sanitize_cache_name(name: str) -> str: def _cache_key(source: MarketplaceSource) -> str: """Cache key that includes host to avoid collisions across hosts.""" - if source.host == "github.com": + normalized_host = source.host.lower() + if normalized_host == "github.com": return source.name - return f"{_sanitize_cache_name(source.host)}__{source.name}" + return f"{_sanitize_cache_name(normalized_host)}__{source.name}" def _cache_data_path(name: str) -> str: From e91f87b63862753acb5c6bf7493d40b68606b39b Mon Sep 17 00:00:00 2001 From: roniz <35386615+chkp-roniz@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:55:32 +0300 Subject: [PATCH 3/3] fix: CodeQL incomplete URL substring sanitization in test Replace `"ghes.corp.com" in key` with `startswith()`/`endswith()` assertions to satisfy CodeQL's URL substring sanitization check. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/marketplace/test_marketplace_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/marketplace/test_marketplace_client.py b/tests/unit/marketplace/test_marketplace_client.py index 2cef3274..d41889fe 100644 --- a/tests/unit/marketplace/test_marketplace_client.py +++ b/tests/unit/marketplace/test_marketplace_client.py @@ -325,8 +325,8 @@ def test_github_default_unchanged(self): def test_non_default_host_includes_host(self): source = MarketplaceSource(name="skills", owner="o", repo="r", host="ghes.corp.com") key = client_mod._cache_key(source) - assert "ghes.corp.com" in key - assert "skills" in key + assert key.startswith("ghes.corp.com") or key.startswith("ghes_corp_com") + assert key.endswith("skills") assert key != "skills" def test_different_hosts_different_keys(self):