Skip to content
Open
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

- Preserve `ssh://` dependency URLs with custom ports for Bitbucket Datacenter repositories instead of silently falling back to HTTPS (#661)
- 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)
Expand Down
9 changes: 7 additions & 2 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,9 +691,14 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
last_error = e
# Continue to next method

# Method 2: Try SSH (works with SSH keys for any host)
# Method 2: Try SSH (works with SSH keys for any host).
# When the user supplied an explicit ssh:// URL (e.g. with a custom port for
# Bitbucket Datacenter), use it verbatim so the port is not silently dropped.
try:
ssh_url = self._build_repo_url(repo_url_base, use_ssh=True, dep_ref=dep_ref)
if dep_ref and dep_ref.original_ssh_url:
ssh_url = dep_ref.original_ssh_url
else:
ssh_url = self._build_repo_url(repo_url_base, use_ssh=True, dep_ref=dep_ref)
repo = Repo.clone_from(ssh_url, target_path, env=clone_env, progress=progress_reporter, **clone_kwargs)
if verbose_callback:
verbose_callback(f"Cloned from: {ssh_url}")
Expand Down
24 changes: 24 additions & 0 deletions src/apm_cli/models/dependency/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class DependencyReference:
None # e.g., "artifactory/github" (repo key path)
)

# Preserved verbatim when the user supplied an explicit ssh:// URL in apm.yml.
# Used by the downloader to clone with the exact URL (including any custom port)
# instead of the reconstructed https:// fallback URL.
original_ssh_url: Optional[str] = None

# Supported file extensions for virtual packages
VIRTUAL_FILE_EXTENSIONS = (
".prompt.md",
Expand Down Expand Up @@ -904,6 +909,24 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
)
)

# Preserve the original ssh:// URL before normalization so the downloader can
# clone with the exact user-supplied URL (e.g. custom port for Bitbucket DC).
# Strip #ref and @alias suffixes — git clone does not accept these; the ref
# is already passed separately via clone_kwargs.
if dependency_str.startswith("ssh://"):
_clone_url = dependency_str.strip()
if "#" in _clone_url:
_clone_url = _clone_url.split("#")[0]
# @alias appears only in the path portion (after scheme://user@host:port/).
# Split on the first three slashes to isolate the path, then strip trailing @alias.
_parts = _clone_url.split("/", 3)
if len(_parts) == 4 and "@" in _parts[3]:
_parts[3] = _parts[3].rsplit("@", 1)[0]
_clone_url = "/".join(_parts)
original_ssh_url = _clone_url
else:
original_ssh_url = None

dependency_str = cls._normalize_ssh_protocol_url(dependency_str)

# Phase 1: detect virtual packages
Expand Down Expand Up @@ -986,6 +1009,7 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
ado_project=ado_project,
ado_repo=ado_repo,
artifactory_prefix=artifactory_prefix,
original_ssh_url=original_ssh_url,
)

def to_github_url(self) -> str:
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_auth_scoping.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,84 @@ def test_clone_env_includes_ssh_connect_timeout(self):
assert "ConnectTimeout" in relaxed["GIT_SSH_COMMAND"]


# ===========================================================================
# Regression: ssh:// URLs with custom ports (issue #661)
# ===========================================================================

class TestCloneWithFallbackSshUrl:
"""Verify that an explicit ssh:// URL is passed verbatim to git clone.

Regression for #661: Bitbucket Datacenter uses custom SSH ports (e.g.
7999). APM was stripping the port during normalisation and then falling
back to https://. The fix stores the original url in
DependencyReference.original_ssh_url and uses it in Method 2 of
_clone_with_fallback so the port is never silently dropped.
"""

def _run_clone_capture_urls(self, dep):
"""Run _clone_with_fallback and return every URL passed to clone_from."""
mock_repo = Mock()
mock_repo.head.commit.hexsha = "abc123"
dl = _make_downloader()
dl.auth_resolver._cache.clear()

called_urls = []

def _fake_clone(url, *a, **kw):
called_urls.append(url)
return mock_repo

with patch.dict(os.environ, {}, clear=True), \
patch(
"apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git",
return_value=None,
), \
patch('apm_cli.deps.github_downloader.Repo') as MockRepo:
MockRepo.clone_from.side_effect = _fake_clone
target = Path(tempfile.mkdtemp())
try:
dl._clone_with_fallback(dep.repo_url, target, dep_ref=dep)
except (RuntimeError, GitCommandError):
pass
finally:
import shutil
shutil.rmtree(target, ignore_errors=True)
return called_urls

def test_bitbucket_datacenter_ssh_with_port_used_verbatim(self):
"""The first clone attempt must use the exact ssh:// URL including port."""
original = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = _dep(original)

assert dep.original_ssh_url == original, "original_ssh_url not stored"

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert urls[0] == original, (
f"Expected first clone URL to be the original ssh:// URL, got: {urls[0]!r}"
)

def test_bitbucket_datacenter_ssh_no_https_attempted_first(self):
"""APM must not attempt https:// before the explicit ssh:// URL."""
original = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = _dep(original)

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert not urls[0].startswith("https://"), (
f"First clone attempt must not be https://, got: {urls[0]!r}"
)

def test_standard_ssh_url_without_port_also_preserved(self):
"""ssh:// without a custom port is also used verbatim."""
original = "ssh://git@github.com/org/repo.git"
dep = _dep(original)

urls = self._run_clone_capture_urls(dep)
assert len(urls) >= 1
assert urls[0] == original


# ===========================================================================
# Object-style dependency entries (parse_from_dict)
# ===========================================================================
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_generic_git_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,48 @@ def test_git_at_url_unchanged(self):
assert result == "git@gitlab.com:acme/repo.git"


class TestBitbucketDatacenterSSH:
"""Regression tests for issue #661: ssh:// URLs with custom ports must be preserved.

Bitbucket Datacenter (and other self-hosted instances) commonly use non-standard
SSH ports (e.g. 7999). When a user explicitly specifies an ssh:// URL in apm.yml
the original URL must be kept verbatim so git clones against the correct port
instead of silently falling back to HTTPS.
"""

def test_preserve_bitbucket_datacenter_ssh_url_with_port(self):
"""ssh:// URL with custom port must be stored in original_ssh_url."""
url = "ssh://git@bitbucket.domain.ext:7999/project/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url

def test_bitbucket_datacenter_host_and_repo_still_parsed(self):
"""Parsed host/repo_url fields should still be populated correctly."""
dep = DependencyReference.parse(
"ssh://git@bitbucket.domain.ext:7999/project/repo.git"
)
assert dep.host == "bitbucket.domain.ext"
assert dep.repo_url == "project/repo"

def test_preserve_standard_ssh_protocol_url(self):
"""ssh:// without a port also stores the original URL."""
url = "ssh://git@github.com/org/repo.git"
dep = DependencyReference.parse(url)
assert dep.original_ssh_url == url

def test_https_url_does_not_set_original_ssh_url(self):
"""HTTPS dependencies must not set original_ssh_url."""
dep = DependencyReference.parse(
"https://bitbucket.domain.ext/scm/project/repo.git"
)
assert dep.original_ssh_url is None

def test_git_at_url_does_not_set_original_ssh_url(self):
"""git@ SSH shorthand does not go through ssh:// normalisation."""
dep = DependencyReference.parse("git@bitbucket.org:acme/rules.git")
assert dep.original_ssh_url is None


class TestCloneURLBuilding:
"""Test that clone URLs are correctly built for generic hosts."""

Expand Down