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 @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`apm install` works with your existing credential chain (SSO, EMU, GHES tokens).** Validation now uses the same credentials as the actual clone (PAT header-injected, then git credential helper, then SSH for explicit `#ref` pins) -- enterprise users whose env-var PAT has narrower SSO/EMU access than their `gh auth setup-git` / OS keychain are no longer false-rejected by the installer's API probe. Validation logic is now a separate module (`github_downloader_validation.py`), laying groundwork for future credential-provider extensibility. (#941)
- **`shared/apm.md` single-credential-group runs no longer fail validation** with a spurious `missing APM bundles: apm-default` -- a normalisation step recreates the per-group subdir layout that `actions/download-artifact@v5+` flattens away. (#1051)
- **`apm pack` works against GitHub Enterprise and other Git hosts** -- honors `GITHUB_HOST` for GHES auth and accepts GitHub / GHES / GitLab / Bitbucket / ADO / SSH URL forms. (#1008)
- **ADO Entra ID auth no longer silently fails.** Bearer tokens from `az account get-access-token` are plumbed through, errors are typed + actionable (4-case diagnostic), and `apm install --update` pre-flights auth before touching files. (#1015)
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Results are cached per-process — the same `(host, org)` pair is resolved once.

All token-bearing requests use HTTPS. Tokens are never sent over unencrypted connections.

`apm install <package>` validation walks the same chain as the actual install: an authenticated attempt with the resolved token first, then a credential-helper fallback (plain HTTPS where the system credential helper provides the token). This means `apm install` from the CLI never rejects a package the lockfile-driven install would accept -- useful when an env-var PAT has narrower SSO/EMU access than the token your `gh auth setup-git` / OS keychain has cached.

## Token lookup

| Priority | Variable | Scope | Notes |
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ When you run `apm install`, APM automatically integrates primitives from install
After installation completes, APM prints a grouped diagnostic summary instead of inline warnings. Categories include collisions (skipped files), cross-package skill replacements, warnings, and errors.

- **Normal mode**: Shows counts and actionable tips (e.g., "9 files skipped -- use `apm install --force` to overwrite")
- **Verbose mode** (`--verbose`): Additionally lists individual file paths grouped by package, full error details, and **the resolved auth source per remote host** (e.g., `[i] dev.azure.com -- using bearer from az cli (source: AAD_BEARER_AZ_CLI)` or `[i] github.com -- token from GITHUB_APM_PAT`). Useful for diagnosing PAT vs. Entra-ID-bearer behaviour against Azure DevOps.
- **Verbose mode** (`--verbose`): Additionally lists individual file paths grouped by package, full error details, and **the resolved auth source per remote host** (e.g., `[i] dev.azure.com -- using bearer from az cli (source: AAD_BEARER_AZ_CLI)` or `[i] github.com -- token from GITHUB_APM_PAT`). Useful for diagnosing PAT vs. Entra-ID-bearer behaviour against Azure DevOps. For subdirectory packages with an explicit `#ref` (e.g. `owner/repo/sub#v1.2.0`), `--verbose` also shows each validation probe attempt -- marker-file lookups, the Contents API directory probe, and the `git ls-remote` fallback -- including which auth step (token, credential-helper, SSH) resolved the ref.

```bash
# See exactly which files were skipped or had issues, and which auth source was used
Expand Down
20 changes: 20 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ export PROXY_REGISTRY_ONLY=1 # optional: proxy-only mode
When `PROXY_REGISTRY_ONLY=1`, APM routes all traffic through the proxy and
never contacts GitHub directly.

## Install validation chain

`apm install <package>` validates a virtual subdirectory package (`owner/repo/path#ref`) before writing it to `apm.yml`. The chain mirrors the actual clone auth path so a credential that succeeds for `git clone` is never false-rejected by the installer:

1. **Marker-file probes** via raw content -- `apm.yml`, `SKILL.md`, `plugin.json`, `README.md`. Fast positive signal; absence is not a failure.
2. **Contents API directory probe** -- `GET /repos/{owner}/{repo}/contents/{path}?ref={ref}`. Confirms the directory exists at the ref.
3. **`git ls-remote`** with the install auth chain (PAT header-injected, then plain HTTPS w/ credential helper, then SSH if `--ssh` or `--allow-protocol-fallback`). Confirms the ref exists.
4. **Shallow `git fetch --depth=1 --filter=tree:0` + `git ls-tree`** at the resolved ref -- the path probe that confirms the subdirectory exists at that ref. Required to close the fail-open hole where step 3 would otherwise pass any successful repo handshake.

Steps 3 and 4 only run for explicit `#ref` pins (not for unpinned default-branch deps), and only when the API steps fail. Azure DevOps tokens (PAT or AAD bearer) are injected via `http.extraheader` (`Authorization: Bearer ...`) and never embedded in the clone URL.

**Yellow signal:** when steps 1-2 fail and steps 3-4 succeed, APM emits a stderr warning -- `[!] API validation skipped for {pkg}; resolved via git credential fallback.` This is security-relevant: a scoped fine-grained PAT may have *correctly* rejected a package on the API surface and the broader git credential chain accepted it. Operators should be able to see that signal in default CI logs.

**Terminal error** when all four steps fail: `[x] all probes failed (marker-file, Contents API, git ls-remote, shallow-fetch) -- verify the path and ref exist and that your credentials have read access (run with --verbose for the full probe log)`.

```bash
# See the full probe log when validation fails
apm install --verbose owner/repo/path#v1.2.0
```

## Troubleshooting

```bash
Expand Down
4 changes: 4 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
| `apm deps clean` | Clean dependency cache | `--dry-run`, `-y` skip confirm |
| `apm deps update [PKGS...]` | Update specific packages | `--verbose`, `--force`, `--target` (comma-separated), `--parallel-downloads N` |

### Install validation chain (virtual subdirectory packages)

`apm install` validates subdirectory packages (`owner/repo/path#ref`) before writing to `apm.yml` using the same credential chain as the actual install. See [Authentication > Install validation chain](../authentication/) for the full probe sequence and troubleshooting.

## Compilation

| Command | Purpose | Key flags |
Expand Down
26 changes: 23 additions & 3 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,29 @@ def _resolve_package_references(
else:
reason = _local_path_failure_reason(dep_ref)
if not reason:
reason = "not accessible or doesn't exist"
if not verbose:
reason += " -- run with --verbose for auth details"
# Round-4 panel fix (devx-ux): name the four-step probe
# chain explicitly when the validator exhausted it
# (virtual subdirectory + explicit ref). Generic "not
# accessible" hides the failure mode for the precise
# case where the most diagnostics are available.
is_subdir_ref_chain = (
dep_ref.is_virtual
and dep_ref.is_virtual_subdirectory()
and bool(dep_ref.reference)
)
if is_subdir_ref_chain:
reason = (
"all probes failed (marker-file, Contents API, "
"git ls-remote, shallow-fetch) -- verify the path "
"and ref exist and that your credentials have "
"read access"
)
if not verbose:
reason += " (run with --verbose for the full probe log)"
else:
reason = "not accessible or doesn't exist"
if not verbose:
reason += " -- run with --verbose for auth details"
invalid_outcomes.append((package, reason))
if logger:
logger.validation_fail(package, reason)
Expand Down
123 changes: 46 additions & 77 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sys
import tempfile
import time # noqa: F401
from collections.abc import Callable # noqa: F401
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union # noqa: F401, UP035
Expand Down Expand Up @@ -1235,92 +1235,61 @@ def _download_github_file(
verbose_callback=verbose_callback,
)

def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
"""Validate that a virtual package (file, collection, or subdirectory) exists on GitHub.

Supports:
- Virtual files: owner/repo/path/file.prompt.md
- Collections: owner/repo/collections/name (checks for .collection.yml)
- Subdirectory packages: owner/repo/path/subdir (checks for apm.yml, SKILL.md, or plugin.json)

Args:
dep_ref: Parsed dependency reference for virtual package
def validate_virtual_package_exists(
self,
dep_ref: DependencyReference,
verbose_callback: Callable[[str], None] | None = None,
warn_callback: Callable[[str], None] | None = None,
) -> bool:
Comment thread
a1icja marked this conversation as resolved.
"""Validate that a virtual package exists at ``dep_ref``.

Returns:
bool: True if the package exists and is accessible, False otherwise
Thin delegation to :func:`github_downloader_validation.validate_virtual_package_exists`
-- see that module for the full validation strategy (marker-file
probes, Contents API directory probe, ``git ls-remote`` fallback).
"""
if not dep_ref.is_virtual:
raise ValueError("Can only validate virtual packages with this method")
from .github_downloader_validation import validate_virtual_package_exists as _v

ref = dep_ref.reference or "main"
file_path = dep_ref.virtual_path
return _v(
self,
dep_ref,
verbose_callback=verbose_callback,
warn_callback=warn_callback,
)

# For collections, check for .collection.yml file
if dep_ref.is_virtual_collection():
file_path = f"{dep_ref.virtual_path}.collection.yml"
try:
self.download_raw_file(dep_ref, file_path, ref)
return True
except RuntimeError:
return False
def _directory_exists_at_ref(
self,
dep_ref: DependencyReference,
path: str,
ref: str,
log: Callable[[str], None],
) -> bool:
Comment thread
a1icja marked this conversation as resolved.
"""Backward-compat shim -- delegates to the validation module."""
from .github_downloader_validation import _directory_exists_at_ref as _impl

# For virtual files, check the file directly
if dep_ref.is_virtual_file():
try:
self.download_raw_file(dep_ref, file_path, ref)
return True
except RuntimeError:
return False

# For subdirectory packages: apm.yml or SKILL.md confirm the type;
# plugin.json confirms a Claude plugin; README.md is a last-resort
# signal that the directory exists (any directory that follows the
# Claude plugin spec may have none of the above).
if dep_ref.is_virtual_subdirectory():
# Try apm.yml first
try:
self.download_raw_file(dep_ref, f"{dep_ref.virtual_path}/apm.yml", ref)
return True
except RuntimeError:
pass
return _impl(self, dep_ref, path, ref, log)

# Try SKILL.md
try:
self.download_raw_file(dep_ref, f"{dep_ref.virtual_path}/SKILL.md", ref)
return True
except RuntimeError:
pass
def _ref_exists_via_ls_remote(
self,
dep_ref: DependencyReference,
ref: str,
log: Callable[[str], None],
) -> bool:
"""Backward-compat shim -- delegates to the validation module.

# Try plugin.json at various plugin locations
plugin_locations = [
f"{dep_ref.virtual_path}/plugin.json", # Root
f"{dep_ref.virtual_path}/.github/plugin/plugin.json", # GitHub Copilot format
f"{dep_ref.virtual_path}/.claude-plugin/plugin.json", # Claude format
f"{dep_ref.virtual_path}/.cursor-plugin/plugin.json", # Cursor format
]
Returns ``bool`` (success only); the underlying impl now also
returns the winning AttemptSpec, but legacy callers only need
the success flag.
"""
from .github_downloader_validation import _ref_exists_via_ls_remote as _impl

for plugin_path in plugin_locations:
try:
self.download_raw_file(dep_ref, plugin_path, ref)
return True
except RuntimeError:
continue
ok, _winning = _impl(self, dep_ref, ref, log)
return ok

# Last resort: README.md -- any well-formed directory should have one.
# A directory that follows the Claude plugin spec (agents/, commands/,
# skills/ ...) with no manifest files is still a valid plugin.
try:
self.download_raw_file(dep_ref, f"{dep_ref.virtual_path}/README.md", ref)
return True
except RuntimeError:
pass
def _ssh_attempt_allowed(self) -> bool:
"""Backward-compat shim -- delegates to the validation module."""
from .github_downloader_validation import _ssh_attempt_allowed as _impl

# Fallback: try to download the file directly
try:
self.download_raw_file(dep_ref, file_path, ref)
return True
except RuntimeError:
return False
return _impl(self)

def download_virtual_file_package(
self,
Expand Down
Loading
Loading