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 @@ -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 @@ -692,9 +692,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)
Comment thread
edenfunf marked this conversation as resolved.
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
Loading