From 1ace57dcaf0abd6b50168bf22c0c3bd1497515b3 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 2 Jun 2026 18:45:07 +0200 Subject: [PATCH 1/2] fix(auth): expand port hint with git credential fill command and docs URL The [i] hint emitted by AuthResolver.build_error_context when dep_ref.port is set named the right problem but not the remedy. Users had to translate 'stores per-port entries' into a concrete debugging step themselves. New three-line hint: [i] Host 'host:port' -- this helper may key by host only. Verify with: printf 'protocol=https\nhost=host:port\n\n' | git credential fill See: https://microsoft.github.io/apm/getting-started/authentication/#custom-port-hosts-and-per-port-credentials Rationale: - git credential fill is the helper-agnostic entry point every credential helper implements; it reproduces exactly what APM sends internally. - The docs URL points to the per-helper compatibility table already in tree. - Matches the wire-format precedent set by the cross-protocol warning (also emits a 'See: ' line). Docs: authentication.md section updated with the same verification command; apm-guide skill mirror updated in parallel. Closes #799 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/getting-started/authentication.md | 8 +++++++- .../.apm/skills/apm-usage/authentication.md | 7 +++++++ src/apm_cli/core/auth.py | 10 ++++++++-- tests/unit/test_auth.py | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index c0dd5887c..9b0751c93 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -457,7 +457,13 @@ For self-hosted Git instances on non-standard ports (e.g. Bitbucket Datacenter o | `libsecret` (Linux) | Yes (port in URI) | | `gh auth git-credential` | No -- but only used for GitHub hosts, which do not use custom ports | -If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port`; otherwise either switch helpers or store credentials under fully qualified `https://:/` URLs. +If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port` using the helper-agnostic verification command: + +```sh +printf 'protocol=https\nhost=:\n\n' | git credential fill +``` + +This reproduces exactly what APM sends to the credential helper. If the returned `username`/`password` are wrong or empty, either switch helpers or store credentials under a fully qualified `https://:/` URL. ### SSH connection hangs on corporate/VPN networks diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 2dcdc17a7..ca05edb10 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -234,6 +234,13 @@ backend: | `libsecret` (Linux) | Yes (port in URI) | | `gh auth git-credential` | No -- but only used for GitHub hosts, which do not use custom ports | +To verify what your helper returns for a custom-port host, use the +helper-agnostic command APM itself calls: + +```sh +printf 'protocol=https\nhost=:\n\n' | git credential fill +``` + If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port`; otherwise either switch helpers or store the credential under a fully qualified `https://:/` URL. diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 1fc6a44fd..a6f9b1c97 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -708,9 +708,15 @@ def build_error_context( # (some `gh` integrations, older keychain backends) can silently # return the wrong credential. Point the user at the concrete fix. if host_info.port is not None: + _docs_url = ( + "https://microsoft.github.io/apm/getting-started/authentication/" + "#custom-port-hosts-and-per-port-credentials" + ) lines.append( - f"[i] Host '{display}' -- verify your credential helper stores per-port entries " - f"(some helpers key by host only)." + f"[i] Host '{display}' -- this helper may key by host only.\n" + f" Verify with: printf 'protocol=https\\nhost={display}\\n\\n'" + f" | git credential fill\n" + f" See: {_docs_url}" ) lines.append("Run with --verbose for detailed auth diagnostics.") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f3bf4a4df..6e23369fa 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1227,6 +1227,24 @@ def test_port_hint_appears_when_port_set(self): msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) assert "per-port" in msg, f"Expected per-port hint when port is set, got:\n{msg}" + def test_port_hint_includes_credential_fill_command(self): + with patch.dict(os.environ, {}, clear=True): + with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): + resolver = AuthResolver() + msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) + assert "git credential fill" in msg, ( + f"Expected 'git credential fill' verification command in hint, got:\n{msg}" + ) + + def test_port_hint_includes_docs_url(self): + with patch.dict(os.environ, {}, clear=True): + with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): + resolver = AuthResolver() + msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) + assert "custom-port-hosts-and-per-port-credentials" in msg, ( + f"Expected docs URL anchor in hint, got:\n{msg}" + ) + def test_no_port_hint_when_port_missing(self): with patch.dict(os.environ, {}, clear=True): with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): From 5ea8027a154648836aff7a8fd29a2fb6a2e2e21f Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 2 Jun 2026 19:15:50 +0200 Subject: [PATCH 2/2] test(auth): fix URL assertion to use urlparse; promote docs URL to constant - Replace substring URL assertion in test_port_hint_includes_docs_url with urllib.parse.urlparse checks per tests.instructions.md (CodeQL py/incomplete-url-substring-sanitization) - Promote _docs_url local variable to module-level _PORT_CREDENTIAL_DOCS_URL constant, matching _PROTOCOL_FALLBACK_DOCS_URL pattern in clone_engine.py - Normalize 'Docs:' prefix in port hint (was 'See:') for consistency with the five other build_error_context URL lines in auth.py - Add CHANGELOG entry for the user-visible credential error improvement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + src/apm_cli/core/auth.py | 11 ++++++----- tests/unit/test_auth.py | 14 ++++++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f0aec69..571d5ba1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Custom-port credential errors now include a ready-to-run `git credential fill` verification command and a link to the auth troubleshooting docs, so users can diagnose miskeyed helpers without guessing. (closes #799) - `apm compile --target copilot` (and `agents`) no longer writes instructions into `AGENTS.md` when `apm install` has already deployed them to `.github/instructions/`, eliminating duplicate context that Copilot would read from both locations. Mirrors the equivalent dedup behaviour that was already in place for the Claude path (`.claude/rules/`). (closes #1550, refs #1445) ### Changed diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index a6f9b1c97..c921e1ebd 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -48,6 +48,11 @@ T = TypeVar("T") +_PORT_CREDENTIAL_DOCS_URL = ( + "https://microsoft.github.io/apm/getting-started/authentication/" + "#custom-port-hosts-and-per-port-credentials" +) + # --------------------------------------------------------------------------- # Data classes @@ -708,15 +713,11 @@ def build_error_context( # (some `gh` integrations, older keychain backends) can silently # return the wrong credential. Point the user at the concrete fix. if host_info.port is not None: - _docs_url = ( - "https://microsoft.github.io/apm/getting-started/authentication/" - "#custom-port-hosts-and-per-port-credentials" - ) lines.append( f"[i] Host '{display}' -- this helper may key by host only.\n" f" Verify with: printf 'protocol=https\\nhost={display}\\n\\n'" f" | git credential fill\n" - f" See: {_docs_url}" + f" Docs: {_PORT_CREDENTIAL_DOCS_URL}" ) lines.append("Run with --verbose for detailed auth diagnostics.") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 6e23369fa..c40811c3e 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -4,6 +4,7 @@ import time from concurrent.futures import ThreadPoolExecutor from unittest.mock import patch +from urllib.parse import urlparse import pytest @@ -1241,8 +1242,17 @@ def test_port_hint_includes_docs_url(self): with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): resolver = AuthResolver() msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) - assert "custom-port-hosts-and-per-port-credentials" in msg, ( - f"Expected docs URL anchor in hint, got:\n{msg}" + # Extract the docs URL from the hint and validate its components with urlparse + # (substring URL assertions are prohibited; see tests.instructions.md) + url_line = next( + (line for line in msg.splitlines() if "microsoft.github.io/apm" in line), None + ) + assert url_line is not None, f"Expected docs URL line in hint, got:\n{msg}" + url = url_line.split()[-1] + parsed = urlparse(url) + assert parsed.hostname == "microsoft.github.io", f"Unexpected hostname: {parsed.hostname}" + assert parsed.fragment == "custom-port-hosts-and-per-port-credentials", ( + f"Unexpected fragment: {parsed.fragment}" ) def test_no_port_hint_when_port_missing(self):