Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fix `apm marketplace add` silently failing for private repos by using credentials when probing `marketplace.json` (#701)
- Pin codex setup to `rust-v0.118.0` for security and reproducibility; update config to `wire_api = "responses"` (#663)
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)
- Fix `apm install` hanging indefinitely when corporate firewalls silently drop SSH packets by setting `GIT_SSH_COMMAND` with `ConnectTimeout=30` (#652)
Expand Down
6 changes: 3 additions & 3 deletions src/apm_cli/marketplace/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Fetch, parse, and cache marketplace.json from GitHub repositories.

Uses ``AuthResolver.try_with_fallback(unauth_first=True)`` for public-first
access with automatic credential fallback for private marketplace repos.
Uses ``AuthResolver.try_with_fallback(unauth_first=False)`` for auth-first
access so private marketplace repos are fetched with credentials when available.
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
Expand Down Expand Up @@ -243,7 +243,7 @@ def _do_fetch(token, _git_env):
source.host,
_do_fetch,
org=source.owner,
unauth_first=True,
unauth_first=False,
)
except Exception as exc:
raise MarketplaceFetchError(source.name, str(exc)) from exc
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/marketplace/test_marketplace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,85 @@ def test_fetch_marketplace_via_proxy_end_to_end(self):
assert manifest.plugins[0].name == "p1"


@patch("apm_cli.marketplace.client._try_proxy_fetch", return_value=None)
class TestPrivateRepoAuth:
"""Verify unauth_first=False so private repos get credentials before unauthenticated fallback.

GitHub returns 404 (not 403) for unauthenticated requests to private repos.
With unauth_first=True the old code would try unauthenticated first, receive a 404, and
silently treat the repo as non-existent. The fix sets unauth_first=False so the token
is used on the first attempt.
"""

_MARKETPLACE_JSON = {"name": "Private Plugins", "plugins": []}

def test_fetch_file_private_repo_auth_first(self, _proxy):
"""_fetch_file passes unauth_first=False so private repos are reached via auth first."""
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)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

assert result == self._MARKETPLACE_JSON
mock_resolver.try_with_fallback.assert_called_once()
_, call_kwargs = mock_resolver.try_with_fallback.call_args
assert call_kwargs.get("unauth_first") is False, (
"unauth_first must be False -- private repos respond 404 to unauthenticated requests"
)

def test_fetch_file_no_proxy_passes_unauth_first_false(self, _proxy):
"""With no proxy, try_with_fallback is explicitly called with unauth_first=False (not True)."""
source = _make_source()
with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None):
mock_resolver = MagicMock()
# Simulate private repo returning None (404) for unauthenticated; would succeed with auth
mock_resolver.try_with_fallback.return_value = None
mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com")

client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver)

mock_resolver.try_with_fallback.assert_called_once()
call_kwargs = mock_resolver.try_with_fallback.call_args.kwargs
assert "unauth_first" in call_kwargs, (
"unauth_first kwarg must be passed explicitly to try_with_fallback"
)
assert call_kwargs["unauth_first"] is False, (
f"Expected unauth_first=False, got {call_kwargs['unauth_first']!r}"
)

def test_auto_detect_private_repo_succeeds_with_auth(self, _proxy):
"""_auto_detect_path finds a private repo's manifest via auth on the third candidate path."""
source = _make_source()
call_count = [0]

def mock_try_with_fallback(host, op, org=None, unauth_first=False):
call_count[0] += 1
if call_count[0] < 3:
# marketplace.json and .github/plugin/marketplace.json: 404 on private repo
return None
# .claude-plugin/marketplace.json: found with auth
return self._MARKETPLACE_JSON

mock_resolver = MagicMock()
mock_resolver.try_with_fallback.side_effect = mock_try_with_fallback
mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com")

with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None):
path = client_mod._auto_detect_path(source, auth_resolver=mock_resolver)

assert path == ".claude-plugin/marketplace.json"
# All three candidates were probed before finding it on the third
assert mock_resolver.try_with_fallback.call_count == 3
# Every probe used unauth_first=False (auth credentials always tried first)
for call in mock_resolver.try_with_fallback.call_args_list:
assert call.kwargs.get("unauth_first") is False, (
f"Expected unauth_first=False for all probes, got {call.kwargs!r}"
)


class TestCacheKey:
"""Cache key includes host for non-github.com sources."""

Expand Down
Loading