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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm install --update` now falls back from a stale `ADO_APM_PAT` to an `az login` AAD bearer in the preflight auth probe, matching the behavior of `apm install` and every other ADO call site. Previously the preflight raised `AuthenticationError` on 401/403 even when `az login` would have succeeded. The bearer env also pops any pre-existing `GIT_TOKEN` so the JWT flows only via `GIT_CONFIG_VALUE_0`, and the per-host stale-PAT warning dedup is lock-guarded so parallel installs against the same ADO host emit one warning instead of one-per-thread. (#1212)
- `Unknown target` error suggestions no longer advertise the `agent-skills` meta-target, which `apm targets` intentionally omits from its table. The canonical set still accepts `agent-skills` via `--target` and `apm.yml`, but the recovery path printed on errors now matches what the discovery command actually lists. (#1215)
- `apm pack` no longer hardcodes `pack.target` into bundles; bundles are target-agnostic and `apm install <bundle>` resolves the consumer target from project context and wires bundle `.mcp.json` servers per target via `MCPIntegrator`. (#1217)
- Multi-account Git Credential Manager users: APM now selects the right GitHub account automatically per repository (no account-picker prompt) when `credential.useHttpPath = true` is set. Existing single-account setups are unaffected. (#1226)

## [0.12.4] - 2026-05-07

Expand Down
27 changes: 27 additions & 0 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,33 @@ The org name comes from the dependency reference — `contoso/my-package` checks

Per-org tokens take priority over global tokens. Use this when different orgs require different PATs (e.g., separate SSO authorizations).

## Multi-account Git Credential Manager

APM forwards the repository path to `git credential fill`, so [Git Credential Manager (GCM)](https://github.com/git-ecosystem/git-credential-manager) can automatically pick the right GitHub account per organization -- no account-picker prompt. Existing single-account setups are unaffected: if `credential.useHttpPath` is not enabled, git credential helpers ignore the `path` attribute and match per host only.

To opt in, enable path-aware matching once:

```bash
git config --global credential.useHttpPath true
```

GCM (v2.1+) matches credential URLs by **prefix**, so a single config entry per org typically covers every repo under that org:

```bash
git config --global credential.https://github.com/acme.username your-acme-account
git config --global credential.https://github.com/personal-org.username your-personal-account
```

With the entries above, fetches against `acme/widgets`, `acme/payments`, and any other `acme/*` repo all resolve to `your-acme-account` without per-repo configuration. Other credential helpers (and older GCM versions) may require an exact path match -- consult your helper's documentation if a per-org entry is not picked up.

### Seeing an account picker mid-install?

If `apm install` triggers a GCM account-picker dialog while resolving a private repo:

1. Confirm `credential.useHttpPath` is set globally: `git config --global --get credential.useHttpPath` should print `true`.
2. Confirm a per-URL entry exists for the org: `git config --global --get-urlmatch credential https://github.com/<org>` should list the username.
3. Re-run with `--verbose`; APM logs `trying git credential fill for <host> (path=<owner>/<repo>)` so you can confirm the path APM is sending matches your config entry.

## Fine-grained PAT setup

Fine-grained PATs (`github_pat_`) are scoped to a **single resource owner** — either a user account or an organization. A user-scoped fine-grained PAT **cannot** access repos owned by an organization, even if you are a member of that org.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ APM checks these sources in order, using the first valid token found:
| 3 | `GITHUB_TOKEN` | Global | Shared with GitHub Actions |
| 4 | `GH_TOKEN` | Global | Set by `gh auth login` |
| 5 | `gh auth token --hostname <host>` | GitHub-like hosts | Active `gh auth login` account |
| 6 | `git credential fill` | Per-host | System credential manager |
| 6 | `git credential fill` | Per-host | System credential manager. APM forwards `path=<owner>/<repo>` so Git Credential Manager users with `credential.useHttpPath = true` get per-URL account selection (no account-picker prompt). |
| -- | None | -- | Unauthenticated (public GitHub repos only) |

APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. If the `gh` CLI is not installed or no account is active, APM skips this step silently and continues to `git credential fill`.

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.

## Per-org setup

Use per-org tokens when accessing packages across multiple organizations:
Expand Down
30 changes: 24 additions & 6 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ def try_with_fallback(
*,
org: str | None = None,
port: int | None = None,
path: str | None = None,
unauth_first: bool = False,
verbose_callback: Callable[[str], None] | None = None,
) -> T:
Expand All @@ -334,9 +335,14 @@ def try_with_fallback(
host:
Target git host.
operation:
``operation(token, git_env) -> T`` the work to do.
``operation(token, git_env) -> T`` -- the work to do.
org:
Optional organisation for per-org token lookup.
path:
Optional repository path (``org/repo``) included in the
``git credential fill`` request so helpers configured with
``credential.useHttpPath = true`` can disambiguate per-URL
(notably Git Credential Manager for multi-account users).
unauth_first:
If *True*, try unauthenticated first (saves rate limits, EMU-safe).
verbose_callback:
Expand All @@ -360,9 +366,11 @@ def _try_credential_fallback(exc: Exception) -> T:

Walks the secondary chain in order: gh CLI (GitHub-like hosts;
internal guard short-circuits unsupported hosts), then
``git credential fill``. Sources already obtained from a
secondary chain (``gh-auth-token``, ``git-credential-fill``,
``none``) skip retry to avoid double-invocation.
``git credential fill`` (with ``path`` when known so
helpers can disambiguate per-URL). Sources already obtained
from a secondary chain (``gh-auth-token``,
``git-credential-fill``, ``none``) skip retry to avoid
double-invocation.
"""
if auth_ctx.source in ("gh-auth-token", "git-credential-fill", "none"):
raise exc
Expand All @@ -381,9 +389,10 @@ def _try_credential_fallback(exc: Exception) -> T:
gh_token,
self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind),
)
_log(f"trying git credential fill for {host_info.display_name}")
path_suffix = f" (path={path})" if path else ""
_log(f"trying git credential fill for {host_info.display_name}{path_suffix}")
cred = self._token_manager.resolve_credential_from_git(
host_info.host, port=host_info.port
host_info.host, port=host_info.port, path=path
)
if cred:
_log(f"git credential fill resolved a credential for {host_info.display_name}")
Expand Down Expand Up @@ -718,6 +727,15 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No

# 4. Git credential helper (not for ADO)
if host_info.kind not in ("ado",):
# Note: path= is intentionally omitted here. _resolve_token is the
# primary credential-resolution leg invoked once per host; it has
# no per-call repository context. The fallback leg in
# _try_credential_fallback re-invokes resolve_credential_from_git
# WITH path= when the primary credential is rejected, so GCM
# multi-account users still get per-URL disambiguation -- they
# just pay one extra round-trip on the first miss. Adding path=
# here would require threading repo context through every
# resolve() call site, which is disproportionate to the benefit.
credential = self._token_manager.resolve_credential_from_git(
host_info.host, port=host_info.port
)
Expand Down
57 changes: 55 additions & 2 deletions src/apm_cli/core/token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import subprocess
import sys
from typing import Dict, Optional, Tuple # noqa: F401, UP035
from urllib.parse import urlparse

from apm_cli.utils.github_host import (
default_host,
Expand All @@ -48,6 +49,44 @@ def _format_credential_host(host: str, port: int | None) -> str:
return f"{host}:{port}" if port is not None else host


def _sanitize_credential_path(path: str) -> str:
"""Strip leading ``/``, reject control chars, allowlist URL schemes.

The git credential protocol is line-oriented: a stray newline in the
``path`` value would let an attacker inject arbitrary attribute lines
(``\\nusername=...`` etc.) into the credential request. Even though
``path`` originates from a parsed dependency reference (already
constrained to URL components), we defensively reject any value that
contains control characters or whitespace, returning an empty string
so the caller skips the ``path=`` line entirely. This preserves the
pre-disambiguation request rather than ever sending a malformed one.

We also guard against accidental full-URL inputs (``https://...``).
Today every caller passes ``owner/repo``, but if a future caller ever
passes a full URL the naive ``lstrip('/')`` would yield
``https:/host/owner/repo`` which GCM silently ignores. Detect this
via ``urlparse`` and use the URL's path component instead.

Schemes are allowlisted to ``https``/``http``/``ssh`` (and the
schemeless owner/repo case). ``urlparse`` is greedy about consuming
embedded characters in non-hierarchical schemes (notably ``data:``
and ``file:``), which would let those URI families bypass the
char-scan -- the ``parsed.path`` after such schemes can still embed
bytes the scan would otherwise reject. Reject anything off-allowlist.
"""
parsed = urlparse(path)
scheme = parsed.scheme.lower()
if scheme and scheme not in ("https", "http", "ssh"):
return ""
cleaned = parsed.path.lstrip("/") if scheme else path.lstrip("/")
if not cleaned:
return ""
for ch in cleaned:
if ord(ch) < 0x20 or ord(ch) == 0x7F or ch.isspace():
return ""
return cleaned


class GitHubTokenManager:
"""Manages GitHub token environment setup for different AI runtimes."""

Expand Down Expand Up @@ -143,7 +182,9 @@ def _get_credential_timeout(cls) -> int:
return max(1, min(val, cls.MAX_CREDENTIAL_TIMEOUT))

@staticmethod
def resolve_credential_from_git(host: str, port: int | None = None) -> str | None:
def resolve_credential_from_git(
host: str, port: int | None = None, path: str | None = None
) -> str | None:
"""Resolve a credential from the git credential store.

Uses `git credential fill` to query the user's configured credential
Expand All @@ -155,15 +196,27 @@ def resolve_credential_from_git(host: str, port: int | None = None) -> str | Non
port: Optional non-standard git port (e.g. 7999 for Bitbucket DC).
Embedded into the ``host`` field per ``gitcredentials(7)`` --
a standalone ``port=`` line is not part of the protocol.
path: Optional repository path (``org/repo``). When provided,
a ``path=`` line is appended to the credential request so
helpers configured with ``credential.useHttpPath = true``
(notably Git Credential Manager for multi-account users)
can disambiguate the target URL and pick the right
stored account without prompting.

Returns:
The password/token from the credential store, or None if unavailable
"""
host_field = _format_credential_host(host, port)
stdin_lines = ["protocol=https", f"host={host_field}"]
if path:
sanitized = _sanitize_credential_path(path)
if sanitized:
stdin_lines.append(f"path={sanitized}")
stdin = "\n".join(stdin_lines) + "\n\n"
try:
result = subprocess.run(
["git", "credential", "fill"],
input=f"protocol=https\nhost={host_field}\n\n",
input=stdin,
capture_output=True,
text=True,
encoding="utf-8",
Expand Down
15 changes: 15 additions & 0 deletions src/apm_cli/install/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Scan a local directory for nested installable packages and hint the user.
"""

import re
from pathlib import Path

import requests
Expand Down Expand Up @@ -524,6 +525,10 @@ def _check_repo(token, git_env):
_check_repo,
org=org,
port=port,
# dep_ref.repo_url is owner/repo (never a full URL per the
# DependencyReference invariant); forwarded as path= so GCM
# multi-account users get per-URL credential matching.
path=dep_ref.repo_url,
unauth_first=True,
verbose_callback=verbose_log,
)
Expand Down Expand Up @@ -555,6 +560,15 @@ def _check_repo(token, git_env):
host = default_host()
org = package.split("/")[0] if "/" in package else None
repo_path = package # owner/repo format
# Defensive owner/repo guard: when DependencyReference.parse raises,
# we fall back to embedding `repo_path` directly into an API URL and
# forwarding it as `path=` to git credential fill. Reject anything
# that isn't a strict <owner>/<repo> slug so path-confusion sequences
# (`../`, embedded slashes, control bytes) cannot reach either sink.
# Allows GitHub's documented owner/repo characters: alphanumeric,
# dot, underscore, hyphen.
if not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo_path):
return False

def _check_repo_fallback(token, git_env):
host_info = auth_resolver.classify_host(host)
Expand Down Expand Up @@ -586,6 +600,7 @@ def _check_repo_fallback(token, git_env):
host,
_check_repo_fallback,
org=org,
path=repo_path,
unauth_first=True,
verbose_callback=verbose_log,
)
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/marketplace/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def _do_fetch(token, _git_env):
source.host,
_do_fetch,
org=source.owner,
path=f"{source.owner}/{source.repo}",
# Auth-first: marketplace repos may be private/org-scoped and the
# GitHub API returns 404 (not 403) for unauthenticated requests to
# private repos. Because _do_fetch returns None on 404 (no
Expand Down
Loading
Loading