From 02cb890130a4d8d3b600229008b90653c903e6b9 Mon Sep 17 00:00:00 2001 From: Alicja Date: Sat, 25 Apr 2026 18:07:08 -0400 Subject: [PATCH 1/4] fix(install): align validation auth chain with install for explicit-ref virtual deps --- CHANGELOG.md | 1 + .../docs/getting-started/authentication.md | 2 + src/apm_cli/deps/github_downloader.py | 274 ++++++++++++++---- src/apm_cli/install/validation.py | 4 +- tests/test_github_downloader.py | 199 +++++++++++++ 5 files changed, 427 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982989477..d91987a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm install ` validation no longer false-rejects subdirectory packages whose root has no marker file (`apm.yml` / `SKILL.md` / `plugin.json` / `README.md`) and packages whose env-var PAT has narrower access than the system Git credential helper. The validator now adds (1) a directory-exists probe via the GitHub Contents API and (2) a `git ls-remote` fallback that mirrors `_clone_with_fallback`'s auth chain (authenticated PAT, then plain HTTPS letting the user's credential helper resolve), gated to packages with an explicit `#ref`. Validation now agrees with what the install pipeline would actually do, so `apm install owner/repo/sub#BR` succeeds wherever the equivalent `- owner/repo/sub#BR` in `apm.yml` does. - `apm install` (user scope): `init_link_resolver` now scopes `discover_primitives` to `~/.apm/` instead of `~/`, preventing recursive-glob across the entire home directory. Fixes #830 (#850) - Audit blindness for local `.apm/` content -- `apm audit --ci` now detects drift, missing files, and content tampering on locally-authored files (not just installed packages). (#887) - Packer leak risk: local-content fields (`local_deployed_files`, `local_deployed_file_hashes`) are now stripped from bundled lockfiles, preventing phantom self-entries on unpack. (#887) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 65721342a..e8cbcaa0a 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -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 ` 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 | diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index ed7bab90f..02568f75a 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -1709,16 +1709,27 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re except requests.exceptions.RequestException as e: raise RuntimeError(f"Network error downloading {file_path}: {e}") - def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool: + def validate_virtual_package_exists( + self, + dep_ref: DependencyReference, + verbose_callback=None, + ) -> 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) + - Subdirectory packages: owner/repo/path/subdir (checks for apm.yml, + SKILL.md, plugin.json, README.md, then falls back to a true + directory-exists probe so validation matches install semantics -- + install just clones the repo at the ref and copies the directory, + marker file or no marker file). Args: dep_ref: Parsed dependency reference for virtual package + verbose_callback: Optional callable for verbose logging. + When provided, each probe attempt is logged so failures are + debuggable without re-running with print statements. Returns: bool: True if the package exists and is accessible, False otherwise @@ -1727,74 +1738,233 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool: raise ValueError("Can only validate virtual packages with this method") ref = dep_ref.reference or "main" - file_path = dep_ref.virtual_path + vpath = dep_ref.virtual_path - # For collections, check for .collection.yml file - if dep_ref.is_virtual_collection(): - file_path = f"{dep_ref.virtual_path}.collection.yml" + def _log(msg: str) -> None: + if verbose_callback: + verbose_callback(msg) + + def _probe(path: str) -> bool: try: - self.download_raw_file(dep_ref, file_path, ref) + self.download_raw_file(dep_ref, path, ref) + _log(f" [+] {path}@{ref}") return True - except RuntimeError: + except RuntimeError as exc: + _log(f" [x] {path}@{ref} ({exc})") return False - # For virtual files, check the file directly + _log(f"Validating virtual package at ref '{ref}': {dep_ref.repo_url}/{vpath}") + + if dep_ref.is_virtual_collection(): + return _probe(f"{vpath}.collection.yml") + if dep_ref.is_virtual_file(): - try: - self.download_raw_file(dep_ref, file_path, ref) - return True - except RuntimeError: - return False + return _probe(vpath) - # 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). + # Subdirectory packages: marker files are a fast positive signal. + # Their absence is NOT a failure -- the two final fallbacks below + # match install semantics so we don't reject paths that install + # would happily clone-and-copy. 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) + marker_paths = [ + f"{vpath}/apm.yml", + f"{vpath}/SKILL.md", + f"{vpath}/plugin.json", + f"{vpath}/.github/plugin/plugin.json", + f"{vpath}/.claude-plugin/plugin.json", + f"{vpath}/.cursor-plugin/plugin.json", + f"{vpath}/README.md", + ] + for marker_path in marker_paths: + if _probe(marker_path): + return True + + # Fallback 1: directory-exists probe via Contents API. + if self._directory_exists_at_ref(dep_ref, vpath, ref, _log): return True - except RuntimeError: - pass - # Try SKILL.md - try: - self.download_raw_file(dep_ref, f"{dep_ref.virtual_path}/SKILL.md", ref) + # Fallback 2: explicit ref + git ls-remote. Mirrors install's + # auth chain so we accept packages whose API auth is stricter + # than their git auth (SSO-half-authorized PATs, fine-grained + # scope mismatches). Only kicks in when the user gave an + # explicit ref -- without one we keep strict validation so + # path typos still fail fast on the default branch. + if dep_ref.reference is not None and self._ref_exists_via_ls_remote( + dep_ref, ref, _log + ): + _log( + f" [+] ref '{ref}' resolves via ls-remote; " + "deferring path validation to install" + ) return True - except RuntimeError: - pass + return False - # 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 - ] + return _probe(vpath) - for plugin_path in plugin_locations: - try: - self.download_raw_file(dep_ref, plugin_path, ref) - return True - except RuntimeError: - continue + def _directory_exists_at_ref( + self, + dep_ref: DependencyReference, + path: str, + ref: str, + log, + ) -> bool: + """Check if a directory exists at the given ref via the Contents API. + + Uses the default ``Accept: application/vnd.github+json`` so the + endpoint returns the directory listing for directories (and file + metadata for files). A 200 means the path resolves at the ref, + which is what install needs. + + Returns ``True`` on 200; ``False`` on 404 or any error. Only + implemented for github.com / GHE; non-GitHub hosts return ``False`` + and rely on the marker-file probes above. + """ + from urllib.parse import quote + from ..utils.github_host import is_github_hostname - # 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) + host = dep_ref.host or default_host() + if dep_ref.is_azure_devops() or not is_github_hostname(host): + log(f" [i] directory-exists probe skipped (host {host} not supported)") + return False + + owner, repo = dep_ref.repo_url.split('/', 1) + token = self.auth_resolver.resolve(host, owner, port=dep_ref.port).token + + # Encode path (preserve '/' as segment separator) and ref (full + # encode -- '/' becomes %2F in the query string). Defends against + # any future ref/path containing reserved URL characters; for + # typical refs/paths the encoded form is identical to the raw form. + encoded_path = quote(path, safe="/") + encoded_ref = quote(ref, safe="") + + host_lc = host.lower() + if host_lc == "github.com": + api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{encoded_path}?ref={encoded_ref}" + elif host_lc.endswith(".ghe.com"): + api_url = f"https://api.{host}/repos/{owner}/{repo}/contents/{encoded_path}?ref={encoded_ref}" + else: + api_url = f"https://{host}/api/v3/repos/{owner}/{repo}/contents/{encoded_path}?ref={encoded_ref}" + + headers = {"Accept": "application/vnd.github+json"} + if token: + headers["Authorization"] = f"token {token}" + + try: + response = self._resilient_get(api_url, headers=headers, timeout=30) + if response.status_code == 200: + log(f" [+] {path}@{ref} (directory)") return True - except RuntimeError: - pass + log(f" [x] {path}@{ref} (HTTP {response.status_code})") + return False + except (requests.exceptions.RequestException, RuntimeError) as exc: + log(f" [x] {path}@{ref} ({exc})") + return False + + def _ref_exists_via_ls_remote( + self, + dep_ref: DependencyReference, + ref: str, + log, + ) -> bool: + """Check if ``ref`` exists in the remote repo via ``git ls-remote``. + + Lenient fallback for when the Contents API rejects a path with 404 + even though ``git clone`` would succeed -- e.g. SSO-half-authorized + PATs, fine-grained PAT scope mismatches between API and git + protocols, or repo policies that gate the Contents API more + strictly than git. + + Mirrors the auth chain in ``_clone_with_fallback``: + + 1. **Authenticated HTTPS** -- explicit PAT in ``self.git_env`` + (silences credential helpers via ``GIT_ASKPASS=echo``). + 2. **Plain HTTPS w/ credential helper** -- token stripped from the + URL, relaxed env, so the user's git credential helper resolves + the credential install ultimately uses. Critical for orgs with + SSO-half-authorized PATs. + 3. **SSH** -- only when the user signaled SSH is acceptable (via + ``--ssh`` or ``--allow-protocol-fallback``). Wrapped in + ``ssh -o BatchMode=yes -o ConnectTimeout=10`` so it never + hangs waiting for a passphrase prompt. + + Returns ``True`` on the first attempt that resolves the ref; + ``False`` if every attempt fails. + """ + if dep_ref.is_artifactory(): + return False + + dep_token = self._resolve_dep_token(dep_ref) + dep_auth_ctx = self._resolve_dep_auth_ctx(dep_ref) + dep_auth_scheme = dep_auth_ctx.auth_scheme if dep_auth_ctx else "basic" + is_insecure = dep_ref.is_insecure + + attempts: list = [] + + # Attempt 1: explicit PAT, locked-down env. Skipped when no token. + if dep_token: + token_env = ( + dep_auth_ctx.git_env + if dep_auth_scheme == "bearer" and dep_auth_ctx is not None + else self.git_env + ) + token_url = self._build_repo_url( + dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref, + token=dep_token, auth_scheme=dep_auth_scheme, + ) + attempts.append(("authenticated HTTPS", token_url, token_env)) + + # Attempt 2: plain HTTPS w/ credential helper. + plain_env = self._build_noninteractive_git_env( + preserve_config_isolation=is_insecure, + suppress_credential_helpers=is_insecure, + ) + plain_url = self._build_repo_url( + dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref, token="", + ) + attempts.append(("plain HTTPS w/ credential helper", plain_url, plain_env)) + + # Attempt 3 (SSH): only when allowed. BatchMode prevents passphrase + # prompts from hanging validation; ssh-agent users still succeed. + if not is_insecure and self._ssh_attempt_allowed(): + try: + ssh_url = self._build_repo_url( + dep_ref.repo_url, use_ssh=True, dep_ref=dep_ref, + ) + ssh_env = dict(plain_env) + ssh_env["GIT_SSH_COMMAND"] = ( + "ssh -o BatchMode=yes -o ConnectTimeout=10" + ) + attempts.append(("SSH", ssh_url, ssh_env)) + except Exception as exc: + log(f" [i] SSH URL build skipped: {exc}") - # Fallback: try to download the file directly + g = git.cmd.Git() + for label, url, env in attempts: + try: + output = g.ls_remote("--heads", "--tags", url, ref, env=env) + if output and output.strip(): + log(f" [+] ls-remote ok via {label}") + return True + log(f" [x] ls-remote returned no matching refs via {label}") + except (GitCommandError, OSError) as exc: + log(f" [x] ls-remote failed via {label}: {exc}") + + return False + + def _ssh_attempt_allowed(self) -> bool: + """Whether the SSH ls-remote attempt should run. + + Mirrors ``_clone_with_fallback``'s gating: SSH is in scope when the + user explicitly preferred it (``--ssh``) or when cross-protocol + fallback is allowed. Default HTTPS-preferring users get no SSH + attempt -- keeps validation output clean and never invokes ssh on + machines that don't have it configured. + """ try: - self.download_raw_file(dep_ref, file_path, ref) - return True - except RuntimeError: + from ..deps.transport_selection import ProtocolPreference + except Exception: return False + return self._protocol_pref == ProtocolPreference.SSH or self._allow_fallback def download_virtual_file_package(self, dep_ref: DependencyReference, target_path: Path, progress_task_id=None, progress_obj=None) -> PackageInfo: """Download a single file as a virtual APM package. diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py index e67baec8c..8a4e38b29 100644 --- a/src/apm_cli/install/validation.py +++ b/src/apm_cli/install/validation.py @@ -133,7 +133,9 @@ def _validate_package_exists(package, verbose=False, auth_resolver=None, logger= if verbose_log: verbose_log(f"Auth resolved: host={host}, org={org}, source={ctx.source}, type={ctx.token_type}") virtual_downloader = GitHubPackageDownloader(auth_resolver=auth_resolver) - result = virtual_downloader.validate_virtual_package_exists(dep_ref) + result = virtual_downloader.validate_virtual_package_exists( + dep_ref, verbose_callback=verbose_log, + ) if not result and verbose_log: try: err_ctx = auth_resolver.build_error_context( diff --git a/tests/test_github_downloader.py b/tests/test_github_downloader.py index 850ef83c7..3921115af 100644 --- a/tests/test_github_downloader.py +++ b/tests/test_github_downloader.py @@ -1832,5 +1832,204 @@ def _fake_download(dep_ref_arg, path, ref): assert parsed["tags"] == ["scope: engineering", "plain-tag"] +class TestRefExistsViaLsRemote: + """Tests for the ``_ref_exists_via_ls_remote`` two/three-attempt chain. + + The chain mirrors ``_clone_with_fallback``'s auth path so validation + accepts what install would actually clone. These tests pin that + behavior so a refactor of the auth chain can't silently regress + validation lenience for users with SSO-half-authorized PATs or + SSH-only setups. + """ + + def _make_dep_ref(self, repo: str = "owner/repo") -> DependencyReference: + return DependencyReference(repo_url=repo) + + def _patch_auth(self, downloader, *, has_token: bool): + """Stub out auth resolution so tests don't hit the real env / git.""" + token = "test-token" if has_token else None + return [ + patch.object(downloader, "_resolve_dep_token", return_value=token), + patch.object(downloader, "_resolve_dep_auth_ctx", return_value=None), + patch.object(downloader, "_build_repo_url", return_value="https://example/repo.git"), + ] + + def _enter(self, ctxs): + return [c.__enter__() for c in ctxs] + + def _exit(self, ctxs): + for c in reversed(ctxs): + c.__exit__(None, None, None) + + def test_first_attempt_with_token_succeeds_short_circuits(self): + """When the authenticated HTTPS attempt resolves the ref, no second attempt fires.""" + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=True) + self._enter(ctxs) + try: + ls_remote_mock = MagicMock(return_value="abc123\trefs/heads/main\n") + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = ls_remote_mock + + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ok is True + assert ls_remote_mock.call_count == 1 + finally: + self._exit(ctxs) + + def test_authenticated_403_falls_back_to_credential_helper(self): + """403 on the PAT attempt MUST trigger the plain-HTTPS attempt.""" + from git.exc import GitCommandError + + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=True) + self._enter(ctxs) + try: + calls = [] + def _ls_remote(*args, **kwargs): + calls.append(args) + if len(calls) == 1: + raise GitCommandError( + ["git", "ls-remote"], 128, b"403", b"Write access not granted", + ) + return "deadbeef\trefs/heads/main\n" + + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = _ls_remote + + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ok is True + assert len(calls) == 2 + finally: + self._exit(ctxs) + + def test_no_token_skips_first_attempt(self): + """Without a resolved token, only the credential-helper attempt should run.""" + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=False) + self._enter(ctxs) + try: + ls_remote_mock = MagicMock(return_value="abc\trefs/heads/main\n") + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = ls_remote_mock + + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ok is True + assert ls_remote_mock.call_count == 1 + finally: + self._exit(ctxs) + + def test_all_attempts_fail_returns_false(self): + """If every attempt errors, the helper returns False (validation rejects).""" + from git.exc import GitCommandError + + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=True) + self._enter(ctxs) + try: + def _always_fail(*args, **kwargs): + raise GitCommandError(["git", "ls-remote"], 128, b"403", b"forbidden") + + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = _always_fail + + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "loo", log=lambda _msg: None, + ) + + assert ok is False + finally: + self._exit(ctxs) + + def test_empty_output_means_ref_not_found(self): + """ls-remote returning no matching refs MUST be treated as a miss, not a hit.""" + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=False) + self._enter(ctxs) + try: + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = MagicMock(return_value=" \n ") + + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "missing", log=lambda _msg: None, + ) + + assert ok is False + finally: + self._exit(ctxs) + + def test_artifactory_dep_short_circuits_without_calling_git(self): + """Artifactory deps have no git surface; helper must not invoke ls-remote.""" + downloader = GitHubPackageDownloader() + dep_ref = DependencyReference( + repo_url="owner/repo", + host="artifactory.example.com", + artifactory_prefix="artifactory/github", + ) + + with patch("git.cmd.Git") as MockGit: + ok = downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ok is False + MockGit.assert_not_called() + + def test_ssh_attempt_skipped_by_default(self): + """Default protocol_pref must NOT add an SSH attempt -- keeps validation quiet.""" + downloader = GitHubPackageDownloader() + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=True) + self._enter(ctxs) + try: + ls_remote_mock = MagicMock(return_value="") + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = ls_remote_mock + + downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ls_remote_mock.call_count == 2 + finally: + self._exit(ctxs) + + def test_ssh_attempt_added_when_protocol_pref_is_ssh(self): + """--ssh / ProtocolPreference.SSH MUST surface an SSH ls-remote attempt.""" + from apm_cli.deps.transport_selection import ProtocolPreference + + downloader = GitHubPackageDownloader() + downloader._protocol_pref = ProtocolPreference.SSH + dep_ref = self._make_dep_ref() + ctxs = self._patch_auth(downloader, has_token=True) + self._enter(ctxs) + try: + ls_remote_mock = MagicMock(return_value="") + with patch("git.cmd.Git") as MockGit: + MockGit.return_value.ls_remote = ls_remote_mock + + downloader._ref_exists_via_ls_remote( + dep_ref, "main", log=lambda _msg: None, + ) + + assert ls_remote_mock.call_count == 3 + finally: + self._exit(ctxs) + + if __name__ == '__main__': pytest.main([__file__]) From f7a2c478f55387716bd0ead7030654ccf976fe0f Mon Sep 17 00:00:00 2001 From: Alicja Date: Sat, 25 Apr 2026 18:53:25 -0400 Subject: [PATCH 2/4] nit(install): fix sig typings --- src/apm_cli/deps/github_downloader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index 02568f75a..b3d8dec26 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -1712,7 +1712,7 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re def validate_virtual_package_exists( self, dep_ref: DependencyReference, - verbose_callback=None, + verbose_callback: Optional[Callable[[str], None]] = None, ) -> bool: """Validate that a virtual package (file, collection, or subdirectory) exists on GitHub. @@ -1806,7 +1806,7 @@ def _directory_exists_at_ref( dep_ref: DependencyReference, path: str, ref: str, - log, + log: Callable[[str], None], ) -> bool: """Check if a directory exists at the given ref via the Contents API. @@ -1864,7 +1864,7 @@ def _ref_exists_via_ls_remote( self, dep_ref: DependencyReference, ref: str, - log, + log: Callable[[str], None], ) -> bool: """Check if ``ref`` exists in the remote repo via ``git ls-remote``. From 20d8446f6a0c059de616bc37819a6dfd390e91ce Mon Sep 17 00:00:00 2001 From: Alicja Date: Sat, 25 Apr 2026 18:54:26 -0400 Subject: [PATCH 3/4] nit(docs): replace em dash with -- --- docs/src/content/docs/getting-started/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index e8cbcaa0a..23e9bdfcb 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -20,7 +20,7 @@ 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 ` 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. +`apm install ` 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 From b5331e8b89e22922292e8b5411efa37940423b9d Mon Sep 17 00:00:00 2001 From: Alicja Date: Sat, 25 Apr 2026 18:56:07 -0400 Subject: [PATCH 4/4] nit(CHANGELOG): follow keep-a-changelog format --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91987a22..f83413a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `apm install ` validation no longer false-rejects subdirectory packages whose root has no marker file (`apm.yml` / `SKILL.md` / `plugin.json` / `README.md`) and packages whose env-var PAT has narrower access than the system Git credential helper. The validator now adds (1) a directory-exists probe via the GitHub Contents API and (2) a `git ls-remote` fallback that mirrors `_clone_with_fallback`'s auth chain (authenticated PAT, then plain HTTPS letting the user's credential helper resolve), gated to packages with an explicit `#ref`. Validation now agrees with what the install pipeline would actually do, so `apm install owner/repo/sub#BR` succeeds wherever the equivalent `- owner/repo/sub#BR` in `apm.yml` does. +- `apm install owner/repo/sub#ref` validation now mirrors `_clone_with_fallback`'s auth chain (Contents API directory probe + `git ls-remote` fallback) so virtual subdirectory packages with an explicit `#ref` no longer false-fail when an env-var PAT is narrower than the user's git credential helper. (#941) - `apm install` (user scope): `init_link_resolver` now scopes `discover_primitives` to `~/.apm/` instead of `~/`, preventing recursive-glob across the entire home directory. Fixes #830 (#850) - Audit blindness for local `.apm/` content -- `apm audit --ci` now detects drift, missing files, and content tampering on locally-authored files (not just installed packages). (#887) - Packer leak risk: local-content fields (`local_deployed_files`, `local_deployed_file_hashes`) are now stripped from bundled lockfiles, preventing phantom self-entries on unpack. (#887)