Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- `apm install` now resolves relative `path:` deps declared by remote monorepo packages when they stay inside the same remote repo, while still rejecting absolute, escaping, or cross-repo paths; closes #1571. (#1732)
- Dependencies with the same path on different git hosts no longer collide in `apm.lock.yaml`; `apm install` keeps GitHub/GitLab PATs off generic-host file downloads, routes bespoke GitLab hosts through `type: gitlab`, and surfaces non-404 download failures with host and endpoint context. Reading private files from a generic non-default host now requires a whole-repo git dependency or explicit backend signal; see the dependency and lockfile docs (closes #773). (#1735)

## [0.19.0] - 2026-06-09

Expand Down
23 changes: 22 additions & 1 deletion docs/src/content/docs/consumer/manage-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ parser. The supported forms:
| SSH protocol | `ssh://git@gitlab.com/acme/repo.git` | SSH with explicit scheme or port. |
| SSH with non-default user | `myuser@host:acme/repo.git` or `ssh://myuser@host/acme/repo.git` | Honors a non-`git` SSH user from the URL — useful for Enterprise Managed User (EMU) accounts or any server where the SSH login is not `git`. Username is validated against `^[a-zA-Z0-9_][a-zA-Z0-9_.+-]*$` (64-char cap); percent-encoded userinfo is rejected. The username is presentation-only and not part of dependency identity. |
| Local path | `./packages/shared` or `/abs/path` | Sibling package on disk. |
| Object form (git) | `{ git: <url>, path: <subpath>, ref: <ref>, alias: <name> }` | Custom directory name (`alias`), nested groups, monorepo subpaths, or anything the string forms cannot express. |
| Object form (git) | `{ git: <url>, path: <subpath>, ref: <ref>, alias: <name>, type: gitlab }` | Aliases, nested groups, monorepo subpaths, bespoke GitLab hosts, or anything string forms cannot express. |
| Marketplace dict | `{ name: <plugin>, marketplace: <mkt>, version: <range> }` | Install a plugin from a registered marketplace. Optional `version` accepts a semver range (e.g. `~2.1.0`). Resolved to a concrete git ref at install time. |
| Registry shorthand | `owner/repo#^2.0.0` with a default registry configured | Routes dep through the default registry instead of git. Default may come from `apm.yml` or `~/.apm/config.json`. Requires `registries` experimental flag. |
| Registry object form | `{ id: owner/repo, version: ^2.0.0 }` | Explicit registry dep. `registry:` optional when a default registry is configured. Requires `registries` experimental flag. |
Expand All @@ -80,6 +80,10 @@ dependencies:
ref: v2.0
alias: security

# Self-managed GitLab on a bespoke hostname
- git: https://code.acme.com/platform/standards.git
type: gitlab

# Local: filesystem path (development only)
- path: ./packages/shared-skills

Expand Down Expand Up @@ -113,6 +117,23 @@ host/repo/ref and downloads the sibling from the same origin. Absolute paths,
paths that escape the repo root, and cross-repo local paths are rejected.
This is for same-repo monorepo siblings, not general workspace semantics.

Use `type: gitlab` only on `git` object entries for self-managed GitLab
instances whose hostname does not make the platform obvious:

```yaml
- git: https://code.acme.com/platform/standards.git
type: gitlab
```

That routes REST file reads and token lookup through the GitLab path without
relying on hostname heuristics. For the token precedence chain, see
[Authentication](../../getting-started/authentication/).

Generic non-default hosts do not receive APM-managed GitHub or GitLab PATs on
the HTTP file-read path. If a private host fails with 401/403, use a whole-repo
git dependency for full clone auth support, or choose the supported HTTP backend
signal (`type: gitlab` for GitLab-compatible hosts, `GITHUB_HOST` for GHES).

For private repos and non-GitHub hosts, see
[Private and org packages](../private-and-org-packages/).

Expand Down
19 changes: 14 additions & 5 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,21 @@ When authentication fails, APM prints a targeted diagnostic instead of a generic

APM must classify a host as GitLab to use **GitLab REST v4** (for example `marketplace.json` fetches and install-time single-file reads). Configuration mirrors GHES-style host overrides:

| Variable | Purpose |
|----------|---------|
| `GITLAB_HOST` | One self-managed GitLab FQDN (e.g. `git.company.com`) |
| `APM_GITLAB_HOSTS` | Several self-managed GitLab FQDNs, comma-separated |
| Signal | Purpose |
|--------|---------|
| `GITLAB_HOST` | Environment variable for one self-managed GitLab FQDN (e.g. `git.company.com`) |
| `APM_GITLAB_HOSTS` | Environment variable for several self-managed GitLab FQDNs, comma-separated |
| `type: gitlab` | Manifest object-form hint for one bespoke GitLab host |

`gitlab.com` is detected automatically. For a single dependency on a bespoke
hostname, use object form instead of a hostname convention:

```yaml
- git: https://code.acme.com/platform/standards.git
type: gitlab
```

`gitlab.com` is detected automatically. For GitLab-class hosts, resolved credentials follow **`GITLAB_APM_PAT` → `GITLAB_TOKEN`** and then **`git credential fill`** (see [GitLab-class hosts](#gitlab-class-hosts-gitlabcom-gitlab_host-apm_gitlab_hosts) under [Token lookup](#token-lookup)). GitHub PAT env vars are not used on GitLab. Use a GitLab personal or project access token with API read access where your policy requires it.
For GitLab-class hosts, resolved credentials follow **`GITLAB_APM_PAT` → `GITLAB_TOKEN`** and then **`git credential fill`** (see [GitLab-class hosts](#gitlab-class-hosts-gitlabcom-gitlab_host-apm_gitlab_hosts) under [Token lookup](#token-lookup)). GitHub PAT env vars are not used on GitLab. Use a GitLab personal or project access token with API read access where your policy requires it.

### REST headers (GitLab vs GitHub)

Expand Down
14 changes: 13 additions & 1 deletion docs/src/content/docs/reference/lockfile-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ Each item in `dependencies` describes one resolved package.

| Field | Type | Required | Notes |
|---|---|---|---|
| `repo_url` | string | yes | Canonical repo URL (e.g. `github.com/owner/repo`). Unique key for the entry, except for virtual and local entries (see below). |
| `repo_url` | string | yes | Canonical repository path or URL. Entry identity is derived from `repo_url`, `host`, and virtual/local markers; see [lockfile identity keys](#lockfile-identity-keys). |
| `host` | string | no | FQDN when not inferable from `repo_url` (e.g. for registry proxies or non-GitHub hosts). |
| `host_type` | string | no | Explicit host-kind hint, currently `gitlab`, copied from object-form `type: gitlab`. |
| `port` | int | no | Non-standard SSH/HTTPS port. Validated to `1..65535` on read. |
| `registry_prefix` | string | no | URL path prefix when resolved through a registry proxy (e.g. `artifactory/github`). |
| `resolved_ref` | string | no | The user-supplied ref from `apm.yml` (`main`, `v1.2.0`, a SHA). |
Expand Down Expand Up @@ -150,6 +151,17 @@ Each item in `dependencies` describes one resolved package.
Fields are emitted only when set. A minimal entry is just `repo_url` plus
`resolved_commit`.

## Lockfile identity keys

Lockfile dependency keys keep `github.com` implicit for migration stability:
existing `github.com` entries remain keyed as `owner/repo`. Local dependencies
use `local_path` directly. Virtual dependencies append `virtual_path` to the
base repo key. Entries for non-default hosts prefix the key with the lowercased
host (`host/owner/repo`), so `github.com/team/skills` and
`gitea.myorg.com/team/skills` can coexist without overwriting each other, and
host casing cannot create duplicate keys. Registry-proxy entries keep the bare
logical key because the proxy host is transport, not package identity.

## Self entry

A project that ships its own primitives (skills, agents, prompts under
Expand Down
16 changes: 16 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ APM checks the active `gh` CLI account before invoking OS credential helpers. Th

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.

## GitLab hosts

`gitlab.com` is detected automatically. For self-managed GitLab, set
`GITLAB_HOST`, list multiple hosts in `APM_GITLAB_HOSTS`, or mark a single
object-form dependency with `type: gitlab`:

```yaml
- git: https://code.acme.com/platform/standards.git
type: gitlab
```

GitLab credentials use `GITLAB_APM_PAT`, then `GITLAB_TOKEN`, then host
credentials. GitHub PAT variables are not used for GitLab-class hosts. See the
main [authentication guide](https://microsoft.github.io/apm/getting-started/authentication/)
for the full host-class precedence rules.

## Per-org setup

Use per-org tokens when accessing packages across multiple organizations:
Expand Down
8 changes: 8 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ instead so `@` remains reserved for git usernames and version syntax.
| `path` | OPTIONAL | Subdirectory or file within the repo (virtual package). |
| `ref` | OPTIONAL | Branch, tag, or commit SHA. |
| `alias` | OPTIONAL | Install under a custom directory name (`^[a-zA-Z0-9._-]+$`). |
| `type` | OPTIONAL | Set to `gitlab` for self-managed GitLab on a bespoke hostname. Generic hosts do not receive APM-managed PATs on HTTP file reads. See the [lockfile spec](https://microsoft.github.io/apm/reference/lockfile-spec/#lockfile-identity-keys) for keying rules. |

```yaml
- git: https://gitlab.com/acme/repo.git
Expand All @@ -150,6 +151,9 @@ instead so `@` remains reserved for git usernames and version syntax.

- git: ssh://git@bitbucket.example.com:7999/project/repo.git # custom SSH port
ref: v1.0

- git: https://code.acme.com/platform/standards.git # bespoke GitLab
type: gitlab
```

### Local (`path`)
Expand Down Expand Up @@ -458,3 +462,7 @@ enterprise security guide for the threat model.
`apm.lock.yaml` records the exact commit SHA for every dependency, regardless
of the ref format in apm.yml. Running `apm install` without `--update` always
uses the locked SHA, ensuring reproducible installs across machines.

Lockfile keys keep `github.com` implicit for migration stability while
non-default hosts add the lowercased host segment. See the [lockfile spec](https://microsoft.github.io/apm/reference/lockfile-spec/#lockfile-identity-keys)
for the full keying rules.
9 changes: 7 additions & 2 deletions src/apm_cli/commands/deps/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False):
if lockfile_path.exists():
lockfile = LockFile.read(lockfile_path)
for dep in lockfile.dependencies.values():
# Lockfile keys match declared_sources format (owner/repo)
dep_key = dep.get_unique_key()
# Orphan / source matching is host-blind: it compares against
# the apm.yml-derived keys above and the host-blind apm_modules/
# filesystem layout. get_unique_key() is the host-qualified
# lockfile dedup key (#773) and must NOT be used here, or a
# non-default-host dep never matches its installed directory
# (it would be wrongly flagged orphaned and lose its Source).
dep_key = dep.get_canonical_dependency_string()
if dep_key and dep_key not in declared_sources:
declared_sources[dep_key] = _deps_list_source_label(
dep.host,
Expand Down
66 changes: 55 additions & 11 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@

# Patterns that indicate a secret value follows. Covers:
# token=VALUE, Authorization: Bearer VALUE, Authorization: Basic VALUE,
# URL credentials (https://user:pass@host), and bare bearer strings.
# URL credentials (https://user:pass@host), and bare PAT-like strings.
_SECRET_RE = re.compile(
r"(?:"
r"(?:token|password|secret|authorization|bearer)" # keyword prefix
r"(?:\s*[:=]\s*|\s+)" # separator
r"[\w.~!*\'();:@&=+$,/?#\[\]\-]{4,}" # value (>= 4 chars)
r"|"
r"://[^:@/\s]+:[^:@/\s]+@" # URL user:pass@
r"|"
r"\b(?:github_pat_[A-Za-z0-9_]{20,}|gh[psour]_[A-Za-z0-9_]{20,})\b" # bare PAT
r")",
re.IGNORECASE,
)
Expand Down Expand Up @@ -181,6 +183,15 @@ class BearerFallbackOutcome(NamedTuple):
bearer_attempted: bool


class AuthCacheKey(NamedTuple):
"""Stable cache key for AuthResolver lookups."""

host: str | None
port: int | None
host_type: str # Empty string represents an absent or canonical host_type.
org: str


class AuthResolver:
"""Single source of truth for auth resolution.

Expand All @@ -194,7 +205,7 @@ def __init__(
logger: object | None = None,
):
self._token_manager = token_manager or GitHubTokenManager()
self._cache: dict[tuple, AuthContext] = {}
self._cache: dict[AuthCacheKey, AuthContext] = {}
self._lock = threading.Lock()
# F2/F3 #852: optional logger lets the install command route the
# verbose auth-source line through CommandLogger and the deferred
Expand All @@ -220,16 +231,23 @@ def set_logger(self, logger: object) -> None:
# -- host classification ------------------------------------------------

@staticmethod
def classify_host(host: str, port: int | None = None) -> HostInfo:
def classify_host(
host: str,
port: int | None = None,
host_type: str | None = None,
) -> HostInfo:
"""Return a ``HostInfo`` describing *host*.

``port`` is carried through onto the returned ``HostInfo`` so that
downstream code (cache keys, credential-helper input, error text)
can discriminate between the same hostname on different ports.
Host-kind classification itself is transport-agnostic -- the port
never influences whether a host is GitHub/GHES/ADO/generic.
``host_type`` is an explicit manifest hint for hosts whose names do
not reveal the backing service.
"""
h = host.lower()
host_type_value = (host_type or "").strip().lower()

if h == "github.com":
return HostInfo(
Expand Down Expand Up @@ -258,6 +276,20 @@ def classify_host(host: str, port: int | None = None) -> HostInfo:
port=port,
)

if host_type_value == "gitlab":
api_base = "https://gitlab.com/api/v4" if h == "gitlab.com" else f"https://{h}/api/v4"
return HostInfo(
host=host,
kind="gitlab",
has_public_repos=True,
api_base=api_base,
port=port,
)
if host_type_value:
raise ValueError(
f"Unsupported dependency host type: {host_type_value}. Supported values: gitlab"
)

# GHES: GITHUB_HOST is set to a non-github.com, non-ghe.com FQDN
ghes_host = os.environ.get("GITHUB_HOST", "").lower()
if (
Expand All @@ -275,12 +307,9 @@ def classify_host(host: str, port: int | None = None) -> HostInfo:
port=port,
)

# GitLab (SaaS + env-configured self-managed) after GHES per spec (no silent GHES GitLab)
# GitLab (SaaS + env-configured self-managed) -- after GHES per spec (no silent GHES -> GitLab)
if is_gitlab_hostname(host):
if h == "gitlab.com":
api_base = "https://gitlab.com/api/v4"
else:
api_base = f"https://{host}/api/v4"
api_base = "https://gitlab.com/api/v4" if h == "gitlab.com" else f"https://{h}/api/v4"
return HostInfo(
host=host,
kind="gitlab",
Expand Down Expand Up @@ -353,12 +382,21 @@ def gitlab_rest_headers(

# -- core resolution ----------------------------------------------------

@staticmethod
def _cache_host_type(host: str, host_type: str | None) -> str:
"""Return the cache-discriminating host_type value for a host."""
value = (host_type or "").strip().lower()
if value == "gitlab" and is_gitlab_hostname(host):
return ""
return value

def resolve(
self,
host: str,
org: str | None = None,
*,
port: int | None = None,
host_type: str | None = None,
) -> AuthContext:
"""Resolve auth for *(host, port, org)*. Cached & thread-safe.

Expand All @@ -368,9 +406,10 @@ def resolve(
``AuthContext``. Also flows into ``git credential fill`` so git's
helpers can return port-specific credentials.
"""
key = (
key = AuthCacheKey(
host.lower() if host else host,
port,
self._cache_host_type(host, host_type),
org.lower() if org else "",
)
with self._lock:
Expand All @@ -384,7 +423,7 @@ def resolve(
# all subsequent callers for the same key become O(1) cache hits.
# Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s). No deadlock
# risk: single lock, never nested.
host_info = self.classify_host(host, port=port)
host_info = self.classify_host(host, port=port, host_type=host_type)
token, source, scheme = self._resolve_token(host_info, org)
token_type = self.detect_token_type(token) if token else "unknown"
git_env = self._build_git_env(token, scheme=scheme, host_kind=host_info.kind)
Expand Down Expand Up @@ -412,7 +451,12 @@ def resolve_for_dep(self, dep_ref: DependencyReference) -> AuthContext:
parts = dep_ref.repo_url.split("/")
if parts:
org = parts[0]
return self.resolve(host, org, port=dep_ref.port)
return self.resolve(
host,
org,
port=dep_ref.port,
host_type=dep_ref.host_type,
)

# -- fallback strategy --------------------------------------------------

Expand Down
Loading
Loading