Skip to content

Commit c75b2e6

Browse files
committed
Merge branch 'main' into fix/preserve-ssh-url-bitbucket-datacenter
2 parents aab6c39 + 1e01f92 commit c75b2e6

File tree

3 files changed

+83
-3
lines changed

3 files changed

+83
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Preserve `ssh://` dependency URLs with custom ports for Bitbucket Datacenter repositories instead of silently falling back to HTTPS (#661)
18+
- Fix `apm marketplace add` silently failing for private repos by using credentials when probing `marketplace.json` (#701)
1819
- Pin codex setup to `rust-v0.118.0` for security and reproducibility; update config to `wire_api = "responses"` (#663)
1920
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)
2021
- Fix `apm install` hanging indefinitely when corporate firewalls silently drop SSH packets by setting `GIT_SSH_COMMAND` with `ConnectTimeout=30` (#652)

src/apm_cli/marketplace/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Fetch, parse, and cache marketplace.json from GitHub repositories.
22
3-
Uses ``AuthResolver.try_with_fallback(unauth_first=True)`` for public-first
4-
access with automatic credential fallback for private marketplace repos.
3+
Uses ``AuthResolver.try_with_fallback(unauth_first=False)`` for auth-first
4+
access so private marketplace repos are fetched with credentials when available.
55
When ``PROXY_REGISTRY_URL`` is set, fetches are routed through the registry
66
proxy (Artifactory Archive Entry Download) before falling back to the
77
GitHub Contents API. When ``PROXY_REGISTRY_ONLY=1``, the GitHub fallback
@@ -243,7 +243,7 @@ def _do_fetch(token, _git_env):
243243
source.host,
244244
_do_fetch,
245245
org=source.owner,
246-
unauth_first=True,
246+
unauth_first=False,
247247
)
248248
except Exception as exc:
249249
raise MarketplaceFetchError(source.name, str(exc)) from exc

tests/unit/marketplace/test_marketplace_client.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,85 @@ def test_fetch_marketplace_via_proxy_end_to_end(self):
315315
assert manifest.plugins[0].name == "p1"
316316

317317

318+
@patch("apm_cli.marketplace.client._try_proxy_fetch", return_value=None)
319+
class TestPrivateRepoAuth:
320+
"""Verify unauth_first=False so private repos get credentials before unauthenticated fallback.
321+
322+
GitHub returns 404 (not 403) for unauthenticated requests to private repos.
323+
With unauth_first=True the old code would try unauthenticated first, receive a 404, and
324+
silently treat the repo as non-existent. The fix sets unauth_first=False so the token
325+
is used on the first attempt.
326+
"""
327+
328+
_MARKETPLACE_JSON = {"name": "Private Plugins", "plugins": []}
329+
330+
def test_fetch_file_private_repo_auth_first(self, _proxy):
331+
"""_fetch_file passes unauth_first=False so private repos are reached via auth first."""
332+
source = _make_source()
333+
with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None):
334+
mock_resolver = MagicMock()
335+
mock_resolver.try_with_fallback.return_value = self._MARKETPLACE_JSON
336+
mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com")
337+
338+
result = client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver)
339+
340+
assert result == self._MARKETPLACE_JSON
341+
mock_resolver.try_with_fallback.assert_called_once()
342+
_, call_kwargs = mock_resolver.try_with_fallback.call_args
343+
assert call_kwargs.get("unauth_first") is False, (
344+
"unauth_first must be False -- private repos respond 404 to unauthenticated requests"
345+
)
346+
347+
def test_fetch_file_no_proxy_passes_unauth_first_false(self, _proxy):
348+
"""With no proxy, try_with_fallback is explicitly called with unauth_first=False (not True)."""
349+
source = _make_source()
350+
with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None):
351+
mock_resolver = MagicMock()
352+
# Simulate private repo returning None (404) for unauthenticated; would succeed with auth
353+
mock_resolver.try_with_fallback.return_value = None
354+
mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com")
355+
356+
client_mod._fetch_file(source, "marketplace.json", auth_resolver=mock_resolver)
357+
358+
mock_resolver.try_with_fallback.assert_called_once()
359+
call_kwargs = mock_resolver.try_with_fallback.call_args.kwargs
360+
assert "unauth_first" in call_kwargs, (
361+
"unauth_first kwarg must be passed explicitly to try_with_fallback"
362+
)
363+
assert call_kwargs["unauth_first"] is False, (
364+
f"Expected unauth_first=False, got {call_kwargs['unauth_first']!r}"
365+
)
366+
367+
def test_auto_detect_private_repo_succeeds_with_auth(self, _proxy):
368+
"""_auto_detect_path finds a private repo's manifest via auth on the third candidate path."""
369+
source = _make_source()
370+
call_count = [0]
371+
372+
def mock_try_with_fallback(host, op, org=None, unauth_first=False):
373+
call_count[0] += 1
374+
if call_count[0] < 3:
375+
# marketplace.json and .github/plugin/marketplace.json: 404 on private repo
376+
return None
377+
# .claude-plugin/marketplace.json: found with auth
378+
return self._MARKETPLACE_JSON
379+
380+
mock_resolver = MagicMock()
381+
mock_resolver.try_with_fallback.side_effect = mock_try_with_fallback
382+
mock_resolver.classify_host.return_value = MagicMock(api_base="https://api.github.com")
383+
384+
with patch("apm_cli.deps.registry_proxy.RegistryConfig.from_env", return_value=None):
385+
path = client_mod._auto_detect_path(source, auth_resolver=mock_resolver)
386+
387+
assert path == ".claude-plugin/marketplace.json"
388+
# All three candidates were probed before finding it on the third
389+
assert mock_resolver.try_with_fallback.call_count == 3
390+
# Every probe used unauth_first=False (auth credentials always tried first)
391+
for call in mock_resolver.try_with_fallback.call_args_list:
392+
assert call.kwargs.get("unauth_first") is False, (
393+
f"Expected unauth_first=False for all probes, got {call.kwargs!r}"
394+
)
395+
396+
318397
class TestCacheKey:
319398
"""Cache key includes host for non-github.com sources."""
320399

0 commit comments

Comments
 (0)