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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
requirements in `openapm-v0.1.md` now cite both behaviors. Parser and audit
diagnostics name `ref` and `apm install --update` when those are the required
repair actions. (#2209)
- SSH clone errors that report a passphrase prompt or public-key denial now add
remediation guidance for key availability, CI deploy keys, and token-backed
HTTPS without echoing captured SSH output. Diagnostic-only; #1976 remains
unresolved. (#2244; refs #1976)

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/consumer/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ authorized for that org.

For the org-private case, see [Private and org packages](../private-and-org-packages/).

## SSH clone prerequisites

APM runs git clones non-interactively. Before using an SSH dependency, make
sure its key is already available to SSH. Unlock a passphrase-protected key
first (for example, with `ssh-add <key-file>`). In CI, load a dedicated deploy
key non-interactively or use token-backed HTTPS.

## GitLab (SaaS or self-managed)

**If `git clone` works, `apm install` works** -- no token is needed for GitLab `path:` files.
Expand Down
7 changes: 7 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ inherited token and authorization-header settings removed.

For multi-account Git Credential Manager setups, see the [Multi-account Git Credential Manager](https://microsoft.github.io/apm/getting-started/authentication/#multi-account-git-credential-manager) section in the main authentication guide.

## SSH clone prerequisites

APM runs git clones non-interactively. Before using an SSH dependency, make
sure its key is already available to SSH. Unlock a passphrase-protected key
first (for example, with `ssh-add <key-file>`). In CI, load a dedicated deploy
key non-interactively or use token-backed HTTPS.

## Marketplace transport

For in-repository plugins from GitLab and generic git marketplaces, an SSH
Expand Down
56 changes: 56 additions & 0 deletions src/apm_cli/deps/bare_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,59 @@ def _wt_action(url: str, env: dict[str, str], target: Path) -> None:
return repo_holder[0]


def _git_failure_text(error: Exception) -> str:
"""Return captured command text for internal marker classification only.

The result may contain credentials, key paths, or remote-controlled text.
Never surface it to users, logs, or exception messages.
"""
parts = [str(error)]
for attr in ("stderr", "stdout"):
stream = getattr(error, attr, None)
if not stream:
continue
if isinstance(stream, bytes):
parts.append(stream.decode("utf-8", errors="replace"))
else:
parts.append(str(stream))
return " ".join(part.strip() for part in parts if part and part.strip())


def _is_ssh_key_auth_failure(error_text: str, last_attempt_scheme: str | None) -> bool:
"""Return True for passphrase or public-key errors from an SSH attempt."""
if (last_attempt_scheme or "").lower() != "ssh":
return False

text = error_text.lower()
markers = (
"enter passphrase for key",
"incorrect passphrase supplied",
"bad passphrase",
"read_passphrase",
"permission denied (publickey)",
)
return any(marker in text for marker in markers)


def _ssh_key_auth_diagnostic(
last_error: Exception | None,
last_attempt_scheme: str | None,
) -> str:
"""Build an SSH key diagnostic, or an empty string when unrelated."""
if last_error is None:
return ""
if not _is_ssh_key_auth_failure(_git_failure_text(last_error), last_attempt_scheme):
return ""
return (
" SSH key authentication failed while APM ran git non-interactively. "
"Verify that the key is available to SSH. For a passphrase-protected key, "
"unlock it before running APM (for example, with 'ssh-add <key-file>'). "
"In CI, load a dedicated deploy key non-interactively, or switch the "
"dependency to token-backed HTTPS. APM does not open an interactive "
"passphrase prompt during clone."
)


def build_clone_failure_message(
*,
repo_url_base: str,
Expand All @@ -733,6 +786,7 @@ def build_clone_failure_message(
configured_github_host: str,
default_host_fn: Callable[[], str],
last_error: Exception | None,
last_attempt_scheme: str | None,
sanitize_git_error: Callable[[str], str],
) -> str:
"""Build the aggregate ``RuntimeError`` message for a failed transport plan.
Expand Down Expand Up @@ -798,6 +852,8 @@ def build_clone_failure_message(
else:
error_msg += "Please check repository access permissions and authentication setup."

error_msg += _ssh_key_auth_diagnostic(last_error, last_attempt_scheme)

if last_error:
sanitized_error = sanitize_git_error(str(last_error))
error_msg += f" Last error: {sanitized_error}"
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/deps/clone_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ def _env_for(attempt: TransportAttempt) -> dict[str, str]:
configured_github_host=os.environ.get("GITHUB_HOST", ""),
default_host_fn=default_host,
last_error=last_error,
last_attempt_scheme=prev_scheme,
sanitize_git_error=host._sanitize_git_error,
)

Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_wave2_commands_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,7 @@ def test_build_clone_failure_message(self) -> None:
configured_github_host="github.com",
default_host_fn=lambda: "github.com",
last_error=None,
last_attempt_scheme=None,
sanitize_git_error=lambda s: s,
)
assert isinstance(msg, str)
Expand Down
195 changes: 195 additions & 0 deletions tests/unit/deps/test_bare_cache_clone_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Regression tests for clone failure diagnostics."""

from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from apm_cli.deps.bare_cache import build_clone_failure_message
from apm_cli.deps.clone_engine import CloneEngine
from apm_cli.deps.transport_selection import (
ProtocolPreference,
TransportAttempt,
TransportPlan,
)
from apm_cli.models.apm_package import DependencyReference


def _clone_failure_message(
*,
stderr: bytes,
attempt_scheme: str,
command_scheme: str | None = None,
) -> str:
"""Build a clone failure message for an SSH diagnostic scenario."""
command_scheme = command_scheme or attempt_scheme
plan = TransportPlan(
attempts=[
TransportAttempt(
scheme=attempt_scheme,
use_token=attempt_scheme == "https",
label=attempt_scheme.upper(),
)
],
strict=True,
)
auth_resolver = MagicMock()
auth_resolver.build_error_context.return_value = ""
last_error = subprocess.CalledProcessError(
128,
["git", "clone", f"{command_scheme}://github.com/owner/repo"],
stderr=stderr,
)

return build_clone_failure_message(
repo_url_base="owner/repo",
plan=plan,
dep_ref=DependencyReference(
repo_url="owner/repo",
host="github.com",
explicit_scheme=attempt_scheme,
),
dep_host="github.com",
is_ado=False,
is_generic=False,
has_ado_token=False,
has_token=False,
auth_resolver=auth_resolver,
configured_github_host="github.com",
default_host_fn=lambda: "github.com",
last_error=last_error,
last_attempt_scheme=attempt_scheme,
sanitize_git_error=lambda value: value,
)


def test_clone_failure_message_explains_passphrase_protected_ssh_key() -> None:
"""SSH passphrase failures must tell users how to unblock non-interactive clones."""
message = _clone_failure_message(
stderr=(
b"Enter passphrase for key '/Users/alice/.ssh/id_ed25519':\n"
b"Permission denied (publickey).\n"
),
attempt_scheme="ssh",
)

assert "SSH key authentication failed" in message
assert "ssh-add <key-file>" in message
assert "Verify that the key is available to SSH" in message
assert "dedicated deploy key" in message
assert "token-backed HTTPS" in message
assert "does not open an interactive passphrase prompt" in message


def test_clone_failure_message_explains_explicit_ssh_publickey_failure() -> None:
"""Explicit SSH publickey denials should get the same non-interactive guidance."""
message = _clone_failure_message(
stderr=b"Permission denied (publickey).\n",
attempt_scheme="ssh",
)

assert "SSH key authentication failed" in message
assert "ssh-add <key-file>" in message
assert "token-backed HTTPS" in message


def test_clone_failure_message_does_not_echo_captured_ssh_stderr() -> None:
"""Classification input must not become user-visible output."""
message = _clone_failure_message(
stderr=(
b"Enter passphrase for key '/Users/alice/.ssh/id_secret':\n"
b"Permission denied (publickey).\n"
),
attempt_scheme="ssh",
)

assert "SSH key authentication failed" in message
assert "/Users/alice/.ssh/id_secret" not in message
assert "Permission denied (publickey)" not in message


def test_clone_failure_message_omits_ssh_diagnostic_for_https_token_failure() -> None:
"""HTTPS credential failures must retain their existing auth diagnostic."""
message = _clone_failure_message(
stderr=b"remote: HTTP Basic: Access denied\nfatal: Authentication failed\n",
attempt_scheme="https",
)

assert "SSH key authentication failed" not in message
assert "ssh-add <key-file>" not in message


def test_clone_failure_message_omits_ssh_diagnostic_for_https_passphrase_text() -> None:
"""Passphrase-like server text must not override the actual HTTPS transport."""
message = _clone_failure_message(
stderr=b"remote: Enter passphrase for key enrollment\nfatal: Authentication failed\n",
attempt_scheme="https",
)

assert "SSH key authentication failed" not in message
assert "ssh-add <key-file>" not in message


def test_clone_failure_message_omits_ssh_diagnostic_for_host_key_failure() -> None:
"""Host-key verification has different remediation from key authentication."""
message = _clone_failure_message(
stderr=b"Host key verification failed.\nfatal: Could not read from remote repository.\n",
attempt_scheme="ssh",
)

assert "SSH key authentication failed" not in message
assert "ssh-add <key-file>" not in message


def test_clone_failure_message_omits_ssh_diagnostic_for_network_failure() -> None:
"""SSH network failures must not be presented as key authentication failures."""
message = _clone_failure_message(
stderr=b"ssh: Could not resolve hostname example.invalid: Name or service not known\n",
attempt_scheme="ssh",
)

assert "SSH key authentication failed" not in message
assert "ssh-add <key-file>" not in message


def test_clone_engine_threads_failed_ssh_scheme_into_diagnostic(tmp_path: Path) -> None:
"""CloneEngine must classify the transport that produced the captured error."""
plan = TransportPlan(
attempts=[TransportAttempt(scheme="ssh", use_token=False, label="SSH")],
strict=True,
)
host = MagicMock()
host._transport_selector.select.return_value = plan
host._protocol_pref = ProtocolPreference.SSH
host._allow_fallback = False
host._resolve_dep_token.return_value = None
host._resolve_dep_auth_ctx.return_value = None
host._build_noninteractive_git_env.return_value = {}
host._build_repo_url.return_value = "ssh://git@github.com/owner/repo"
host._sanitize_git_error.side_effect = lambda value: value
host.auth_resolver.build_error_context.return_value = ""
host.has_ado_token = False
engine = CloneEngine(host)

def _fail_clone(_url: str, _env: dict[str, str], _target: Path) -> None:
raise subprocess.CalledProcessError(
128,
["git", "clone", "ssh://git@github.com/owner/repo"],
stderr=b"Enter passphrase for key '/home/alice/.ssh/id_secret':\n",
)

dep_ref = DependencyReference.parse("ssh://git@github.com/owner/repo")
with pytest.raises(RuntimeError) as exc_info:
engine.execute(
dep_ref.repo_url,
tmp_path / "repo",
dep_ref=dep_ref,
clone_action=_fail_clone,
)

message = str(exc_info.value)
assert "SSH key authentication failed" in message
assert "/home/alice/.ssh/id_secret" not in message
Loading