Skip to content
Open
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 @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm install plugin@mkt` for an in-repository plugin of a `github.com` marketplace registered over SSH pre-flights the repository over SSH instead of HTTPS-only probes, so a private marketplace installs with an SSH key and no GitHub token. (#3164)
- Codex agent conversion preserves native `model` and `model_reasoning_effort` settings and warns about dropped metadata instead of silently losing it. (#3150)
- Cursor hooks use native v1 events and flat handlers; unsupported input and overlapping Claude imports now fail explicitly. Choose one hook route per dependency; see the [supported mappings](docs/src/content/docs/producer/author-primitives/hooks-and-commands.md#cursor-native-hooks-and-claude-import) and `openapm-v0.1.md` requirements `req-tg-016/017`. (#3149)

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/consumer/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ For non-interactive environments (CI, devcontainers), set the credential through
For in-repository plugins from GitLab and generic git marketplaces, an SSH
registration stays SSH when APM generates the concrete `git:` and `path:`
dependency. Existing SSH keys keep working instead of the dependency being
rewritten to HTTPS. See
rewritten to HTTPS. A `github.com` marketplace registered over SSH is
pre-flighted with `git ls-remote` over SSH as well, so a private catalog needs
only an SSH key, not a GitHub token. See
[Installing from marketplaces](../installing-from-marketplaces/#local-and-self-hosted-marketplaces).

## Going further
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ browse / install / update workflow works against:
For in-repository plugins from GitLab and generic git marketplaces, APM
keeps this consumer-selected SSH transport when it writes their concrete
`git:` and `path:` entry to `apm.yml`. HTTPS registrations likewise remain
HTTPS.
HTTPS. This includes a `github.com` marketplace registered over SSH: the
install pre-flight checks its in-repository plugins with `git ls-remote`
over SSH, so a private catalog needs only an SSH key, not a GitHub token.
Comment on lines +102 to +104

Remote package entries keep their own source identity independently of the
marketplace registration. Version lookup uses the package repository's tags,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ APM uses the selected transport for clone/fetch and semver tag discovery. When
For in-repository plugins from GitLab and generic git marketplaces, an SSH
registration stays SSH when APM generates the concrete `git:` and `path:`
dependency. Existing SSH keys keep working instead of the dependency being
rewritten to HTTPS.
rewritten to HTTPS. A `github.com` marketplace registered over SSH is
pre-flighted with `git ls-remote` over SSH as well, so a private catalog needs
only an SSH key, not a GitHub token.

Generic marketplace HTTPS sources may use the native Git credential helper from
your Git configuration. Generic HTTP fetches suppress credential channels, HTTPS-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,11 @@ def _check_marketplace_package_remote(provider: FactsProvider) -> tuple[Violatio
"resolver_kwargs['port'] = port",
),
),
(
"src/apm_cli/install/validation.py",
"_uses_clone_root_probe",
("initial_transport_scheme(dep_ref) == 'ssh'",),
),
)
findings: list[Violation] = []
for path, function, required in contracts:
Expand Down
13 changes: 12 additions & 1 deletion src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
# this module and ``tests/unit/test_install_scanning.py``'s direct import
# (``from apm_cli.commands.install import _pre_deploy_security_scan``) keep
# working without modification.
from apm_cli.deps.transport_selection import initial_transport_scheme as _initial_scheme
from apm_cli.install.finalization import close_install_contexts
from apm_cli.install.helpers.security_scan import _pre_deploy_security_scan # noqa: F401
from apm_cli.install.insecure_policy import (
Expand Down Expand Up @@ -117,6 +118,7 @@
from apm_cli.install.validation import (
_local_path_failure_reason,
_local_path_no_markers_hint, # noqa: F401 -- re-exported; test_architecture_invariants checks importability
_uses_clone_root_probe,
_validate_package_exists,
)
from apm_cli.models.results import InstallDisposition, InstallResult
Expand Down Expand Up @@ -512,11 +514,13 @@ def warning_handler(msg):
# chain explicitly when the validator exhausted it
# (virtual subdirectory + explicit ref). Generic "not
# accessible" hides the failure mode for the precise
# case where the most diagnostics are available.
# case where the most diagnostics are available. A clone-root
# ``git ls-remote`` probe never ran that chain.
is_subdir_ref_chain = (
dep_ref.is_virtual
and dep_ref.is_virtual_subdirectory()
and bool(dep_ref.reference)
and not _uses_clone_root_probe(dep_ref)
)
if is_subdir_ref_chain:
reason = (
Expand All @@ -527,6 +531,13 @@ def warning_handler(msg):
)
if not verbose:
reason += " (run with --verbose for the full probe log)"
elif _uses_clone_root_probe(dep_ref) and _initial_scheme(dep_ref) == "ssh":
reason = (
"git ls-remote over SSH failed -- verify that your SSH "
"key has read access to the repository"
)
if not verbose:
reason += " (run with --verbose for the git output)"
else:
reason = "not accessible or doesn't exist"
if not verbose:
Expand Down
39 changes: 27 additions & 12 deletions src/apm_cli/install/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,29 @@ def _check_repo_fallback(token, git_env) -> bool:
return False


def _uses_clone_root_probe(dep_ref) -> bool:
"""Return True when a virtual subdirectory is validated with ``git ls-remote``.

A virtual subdirectory on a non-GitHub, non-ADO host validates its clone
root with git rather than the virtual downloader, so SSH/credential-helper
flows are preserved. The same applies on a GitHub host when the initial
transport is SSH (e.g. an SSH-registered marketplace, #3164): the
downloader probes are HTTPS-only, while the download step uses SSH.
"""
from apm_cli.deps.transport_selection import initial_transport_scheme
from apm_cli.utils.github_host import is_github_hostname

return bool(
dep_ref.is_virtual
and dep_ref.is_virtual_subdirectory()
and (
not is_github_hostname(dep_ref.host or default_host())
or initial_transport_scheme(dep_ref) == "ssh"
)
and not dep_ref.is_azure_devops()
)


def _validate_package_exists(
package,
verbose=False,
Expand Down Expand Up @@ -875,7 +898,6 @@ def _validate_package_exists(

try:
from apm_cli.models.apm_package import DependencyReference
from apm_cli.utils.github_host import is_github_hostname

if dep_ref is None:
dep_ref = DependencyReference.parse(package)
Expand All @@ -884,19 +906,12 @@ def _validate_package_exists(
if dep_ref.is_local and dep_ref.local_path:
return _validate_local_package(dep_ref, logger)

# ``virtual_subdir_repo_probe``: a virtual subdirectory on a non-GitHub,
# non-ADO host must validate the clone root via git ls-remote rather than
# the virtual downloader so SSH/credential-helper flows are preserved.
virtual_subdir_repo_probe = (
dep_ref.is_virtual
and dep_ref.is_virtual_subdirectory()
and not is_github_hostname(dep_ref.host or default_host())
and not dep_ref.is_azure_devops()
)
virtual_subdir_repo_probe = _uses_clone_root_probe(dep_ref)

# For virtual packages, use the downloader's validation method unless
# the virtual path is a subdirectory on a non-GitHub host. Those should
# validate the clone root with git, preserving SSH/credential-helper flows.
# the virtual path is a subdirectory on a non-GitHub host or is pinned
# to SSH. Those validate the clone root with git, preserving
# SSH/credential-helper flows.
if dep_ref.is_virtual and not virtual_subdir_repo_probe:
return _validate_virtual_package(
dep_ref, auth_resolver, verbose, verbose_log, package, logger
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/test_architecture_marketplace_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
RULE = "transport-platform-marketplace-package-remote"
RESOLVER = "src/apm_cli/marketplace/resolver.py"
VERSION = "src/apm_cli/marketplace/version_resolver.py"
VALIDATION = "src/apm_cli/install/validation.py"


def test_marketplace_remote_owner_is_compliant() -> None:
Expand Down Expand Up @@ -49,6 +50,11 @@ def test_marketplace_remote_owner_is_compliant() -> None:
"return DependencyReference.parse(source.url)",
),
(RESOLVER, 'elif source_type in {"github", "git-subdir", "gitlab"}:', "elif False:"),
(
VALIDATION,
'initial_transport_scheme(dep_ref) == "ssh"',
'(getattr(dep_ref, "explicit_scheme", None) or "") == "ssh"',
),
],
ids=[
"catalog-instead-of-package",
Expand All @@ -61,6 +67,7 @@ def test_marketplace_remote_owner_is_compliant() -> None:
"lookup-return-handoff",
"bare-source-authority",
"explicit-source-validation",
"validation-probe-scheme-owner",
],
)
def test_marketplace_remote_owner_rejects_mutation(path: str, old: str, new: str) -> None:
Expand Down
191 changes: 191 additions & 0 deletions tests/unit/install/test_validation_github_ssh_subdir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Pre-flight for SSH-registered github.com marketplace plugins (issue #3164).

Regression: a marketplace registered as ``ssh://git@github.com/org/repo.git``
resolves an in-repo plugin to a virtual subdirectory reference with
``explicit_scheme == "ssh"``. The pre-flight sent every GitHub-host virtual
package to the HTTPS downloader probes (raw, Contents API, anonymous HTTPS
``git ls-remote``), so a private marketplace failed validation without a
token even though the download step clones over SSH.

After the fix, the explicit SSH scheme routes the subdirectory to the
``git ls-remote`` clone-root probe, the same path non-GitHub hosts take
since #2063. HTTPS registrations keep the downloader probes.
"""

from __future__ import annotations

import subprocess
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse

import pytest

from apm_cli.install import validation
from apm_cli.install.errors import AuthenticationError
from apm_cli.marketplace.models import (
MarketplaceManifest,
MarketplacePlugin,
MarketplaceSource,
)
from apm_cli.marketplace.resolver import resolve_marketplace_plugin
from apm_cli.models.apm_package import DependencyReference

SHA = "0123456789abcdef0123456789abcdef01234567"


@pytest.fixture(autouse=True)
def _no_fallback_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("APM_ALLOW_PROTOCOL_FALLBACK", "GITHUB_TOKEN", "GH_TOKEN", "GITHUB_APM_PAT"):
monkeypatch.delenv(name, raising=False)


def _resolve(url: str) -> DependencyReference:
source = MarketplaceSource(name="mkt", url=url)
manifest = MarketplaceManifest(
name="mkt",
plugins=(MarketplacePlugin(name="alpha", source="./plugins/alpha"),),
)
with (
patch("apm_cli.marketplace.resolver.get_marketplace_by_name", return_value=source),
patch("apm_cli.marketplace.resolver.fetch_or_cache", return_value=manifest),
):
result = resolve_marketplace_plugin("alpha", "mkt")
dep_ref = result.dependency_reference
if dep_ref is None:
dep_ref = DependencyReference.parse(result.canonical)
dep_ref.reference = SHA
return dep_ref


def _resolver_without_token() -> MagicMock:
resolver = MagicMock()
host_info = MagicMock(kind="github", display_name="github.com", host="github.com", port=None)
resolver.classify_host.return_value = host_info
ctx = MagicMock(source="none", token_type="unknown", token=None, auth_scheme="basic")
ctx.git_env = {}
resolver.resolve.return_value = ctx
resolver.resolve_for_dep.return_value = ctx
resolver.git_env_for_remote.return_value = {}
resolver.build_error_context.return_value = "No token available."
return resolver


def _run_result(returncode: int, stderr: str = "") -> subprocess.CompletedProcess[str]:
stdout = f"{SHA}\tHEAD\n" if returncode == 0 else ""
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)


def _probe_urls(mock_run: MagicMock) -> list[str]:
return [call.args[0][-1] for call in mock_run.call_args_list]


@pytest.mark.parametrize(
"registered_url",
[
"ssh://git@github.com/org/private-mkt.git",
"git@github.com:org/private-mkt.git",
],
ids=["ssh_url", "scp_shorthand"],
)
def test_ssh_github_marketplace_subdir_probes_over_ssh_only(registered_url: str) -> None:
dep_ref = _resolve(registered_url)
assert dep_ref.host == "github.com"
assert dep_ref.explicit_scheme == "ssh"
assert dep_ref.is_virtual_subdirectory()

with (
patch.object(validation, "_validate_virtual_package", return_value=False) as https_probe,
patch("subprocess.run", return_value=_run_result(0)) as mock_run,
):
ok = validation._validate_package_exists(
dep_ref.to_canonical(),
auth_resolver=_resolver_without_token(),
dep_ref=dep_ref,
)

https_probe.assert_not_called()
assert ok is True
urls = _probe_urls(mock_run)
assert urls == ["git@github.com:org/private-mkt.git"], urls


def test_ssh_github_marketplace_subdir_ignores_available_token() -> None:
"""A reachable GitHub token must not add an HTTPS probe in strict SSH mode."""
dep_ref = _resolve("ssh://git@github.com/org/private-mkt.git")
resolver = _resolver_without_token()
ctx = resolver.resolve_for_dep.return_value
ctx.token = "ghp_example"
ctx.source = "GITHUB_TOKEN"
failure = _run_result(128, "fatal: Could not read from remote repository.")

with (
patch.object(validation, "_validate_virtual_package", return_value=True) as https_probe,
patch("subprocess.run", return_value=failure) as mock_run,
):
try:
ok = validation._validate_package_exists(
dep_ref.to_canonical(),
auth_resolver=resolver,
dep_ref=dep_ref,
)
except AuthenticationError:
ok = False

assert ok is False
https_probe.assert_not_called()
assert _probe_urls(mock_run) == ["git@github.com:org/private-mkt.git"]


def test_ssh_github_marketplace_subdir_auth_failure_still_fails() -> None:
dep_ref = _resolve("ssh://git@github.com/org/private-mkt.git")
failure = _run_result(128, "git@github.com: Permission denied (publickey).")

with (
patch.object(validation, "_validate_virtual_package", return_value=True) as https_probe,
patch("subprocess.run", return_value=failure) as mock_run,
):
try:
ok = validation._validate_package_exists(
dep_ref.to_canonical(),
auth_resolver=_resolver_without_token(),
dep_ref=dep_ref,
)
except AuthenticationError:
ok = False

assert ok is False
https_probe.assert_not_called()
urls = _probe_urls(mock_run)
assert urls
assert {urlparse(url).scheme or "scp" for url in urls} <= {"ssh", "scp"}, urls


def test_https_github_marketplace_subdir_keeps_downloader_probe() -> None:
dep_ref = _resolve("https://github.com/org/public-mkt.git")
assert dep_ref.explicit_scheme != "ssh"

with (
patch.object(validation, "_validate_virtual_package", return_value=True) as https_probe,
patch.object(validation, "_validate_ado_git_package") as git_probe,
):
ok = validation._validate_package_exists(
dep_ref.to_canonical(),
auth_resolver=_resolver_without_token(),
dep_ref=dep_ref,
)

assert ok is True
https_probe.assert_called_once()
git_probe.assert_not_called()


@pytest.mark.parametrize(
("registered_url", "expected"),
[
("ssh://git@github.com/org/private-mkt.git", True),
("https://github.com/org/private-mkt.git", False),
],
ids=["ssh", "https"],
)
def test_clone_root_probe_follows_initial_transport(registered_url: str, expected: bool) -> None:
assert validation._uses_clone_root_probe(_resolve(registered_url)) is expected
Loading