fix: share AuthResolver across install to prevent duplicate auth popups#424
fix: share AuthResolver across install to prevent duplicate auth popups#424frblondin wants to merge 15 commits intomicrosoft:mainfrom
Conversation
Create a single AuthResolver at the top of the install command and thread it through _validate_and_add_packages_to_apm_yml, _validate_package_exists, and _install_apm_dependencies so that all validation and download steps within one CLI invocation share the same credential cache. Also fixes a lock inversion in AuthResolver.resolve() that allowed two concurrent callers for the same (host, org) key to each trigger their own credential-helper lookup before either result was cached.
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent repeated OS credential-helper prompts during apm install by sharing a single AuthResolver instance across install phases and tightening AuthResolver.resolve() caching under concurrency.
Changes:
- Pass a shared
AuthResolverthrough install validation and dependency download flows to reuse the in-memory auth cache. - Make
AuthResolver.resolve()populate/cache within a single lock to avoid concurrent duplicate credential resolutions. - Add unit tests covering concurrent singleflight caching and ensuring downloader construction does not eagerly invoke credential helper resolution.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/commands/install.py |
Wires a shared AuthResolver through validation and install paths. |
src/apm_cli/core/auth.py |
Consolidates resolve cache check/fill under one lock for concurrency safety. |
src/apm_cli/deps/github_downloader.py |
Avoids credential resolution at downloader construction time; keeps auth lazy/per-dependency. |
tests/unit/test_auth.py |
Adds concurrency test asserting singleflight cache behavior. |
tests/test_github_downloader.py |
Adds regression test asserting constructor does not call git credential helper. |
dist.old/apm-windows-x86_64.sha256 |
Adds a checksum artifact file. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
danielmeppiel
left a comment
There was a problem hiding this comment.
Expert Panel Review — Security, Architecture, UX
Thanks for the contribution @frblondin! The core direction is architecturally sound — threading a single AuthResolver through the install pipeline and consolidating the TOCTOU lock race are both correct fixes. However, the implementation has gaps that would leave users still seeing multiple auth popups and could break credential-helper-only users.
Reviewed by three specialized perspectives: Auth/Security, Python Architecture, and CLI UX.
What is good
- Lock consolidation in
resolve()is correct and essential. The old split-lock had a textbook TOCTOU race — N threads each miss the cache between twowith self._lockblocks, each invokegit credential fill, causing N popups. The single-lock fix is bounded by the existing 60s subprocess timeout (APM_GIT_CREDENTIAL_TIMEOUT). No deadlock possible (single lock, no nesting). - Parameter threading pattern (
auth_resolver=Nonewith fallback) follows existing conventions inGitHubPackageDownloader.__init__. - Tests are well-targeted — concurrent singleflight test and no-eager-credential-helper test cover the key scenarios.
- Import safety —
auth.pydepends only on stdlib + internal utils, so moving it outsideAPM_DEPS_AVAILABLEis safe.
Critical — must fix before merge
1. Incomplete cache sharing — 3 of 4 resolver creation sites are not connected
The PR threads the resolver through _validate_and_add_packages_to_apm_yml but misses three other sites. A user installing a private GHE package would still see 3 auth popups instead of the promised 1:
| Site | Location | Shared? |
|---|---|---|
_validate_package_exists L225 |
auth_resolver param |
Yes |
_validate_package_exists L271 |
GitHubPackageDownloader() — no auth_resolver= |
No |
_install_apm_dependencies L1070 |
GitHubPackageDownloader() — no auth_resolver= |
No |
| Verbose auth logging L1431 | _auth_resolver = AuthResolver() |
No |
Fix: Pass auth_resolver=auth_resolver to all GitHubPackageDownloader() calls. For L1431, reuse downloader.auth_resolver instead of creating a fresh instance.
2. Lazy init in GitHubPackageDownloader breaks credential-helper-only users
The constructor no longer calls self.auth_resolver.resolve(default_host()), which means self.github_token, self.has_github_token, and self.has_ado_token are now set from env vars only (token_manager.get_token_for_purpose). Users who rely solely on gh auth login (credential helper, no env vars) will have self.github_token = None and self.has_github_token = False even though credentials are available.
This affects at least:
_build_repo_url()L516 —self.github_tokenfallback silently produces unauthenticated URL_clone_with_fallback()L595 —dep_tokenfalls back toNone- Error handling L667 —
elif not self.has_github_token:may produce wrong error messages
Recommended fix: Keep _setup_git_environment() eager in __init__ but remove only the resolve() calls from it. The env-only token lookup is fine for setting initial state. Per-dependency auth resolution already happens in _clone_with_fallback() L590 via self.auth_resolver.resolve_for_dep(dep_ref). Alternatively, use @property lazy init so these attributes are populated on first access.
3. GHES/GHE _build_repo_url without per-dep auth (also flagged by Copilot inline review)
At L296, the non-github.com validation path builds a URL via GitHubPackageDownloader._build_repo_url(...) without resolving per-dependency auth first. Since the constructor no longer resolves credentials eagerly, package_url will be tokenless for GHES/GHE hosts, and git ls-remote will trigger an OS credential popup — reintroducing the exact problem this PR targets.
Fix: Resolve auth for this dep_ref via the shared auth_resolver before building the URL:
ctx = auth_resolver.resolve_for_dep(dep_ref)
# then pass token=ctx.token to _build_repo_url or use try_with_fallbackMedium — should fix in this PR
4. Remove dist.old/apm-windows-x86_64.sha256
This is an accidental build artifact. Commit 2 empties it but the file is still tracked. It should be fully removed (git rm) — nothing in the codebase references dist.old/, and dist/ is already in .gitignore.
5. Import placement — verify final state
Commit 1 puts from ..core.auth import AuthResolver inside the try: APM_DEPS_AVAILABLE block. Commit 3 moves it outside. The final state is correct, but please verify that the intent is clear: AuthResolver must be unconditionally importable (it has no optional deps), so it should live with the other top-level imports, not inside the APM_DEPS_AVAILABLE guard.
Low — nice to have
6. Document the lock hold trade-off
A brief comment in resolve() explaining why the lock is held during credential resolution would help future maintainers:
# Hold lock during entire resolution to prevent duplicate credential-helper
# popups when parallel downloads resolve the same (host, org) concurrently.
# Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s). After first resolution,
# all subsequent calls for the same key are O(1) cache hits.7. _default_github_ctx is dead code
self._default_github_ctx is assigned but never read anywhere in the codebase. Consider removing it entirely to reduce confusion.
Summary
| Finding | Severity | Status |
|---|---|---|
| 3 of 4 resolver sites not connected | Critical | Must fix |
| Lazy init breaks credential-helper users | Critical | Must fix |
| GHES _build_repo_url without auth | Critical | Must fix |
| dist.old artifact tracked | Medium | Should fix |
| Import placement clarity | Medium | Should fix |
| Lock hold documentation | Low | Nice to have |
Dead _default_github_ctx field |
Low | Nice to have |
The architectural direction is right. Once the three critical items are addressed, this will be a solid fix. Happy to re-review after changes!
The dist.old/apm-windows-x86_64.sha256 checksum file was an accidental build artifact committed to the repo. Nothing in the codebase references dist.old/, and dist/ is already in .gitignore.
The verbose auth logging block inside _install_apm_dependencies was creating a fresh AuthResolver() instance instead of reusing the shared resolver passed to the function. This meant the resolved credentials were not cached, potentially triggering duplicate credential-helper popups when the resolver was used for logging after downloads. Replace the per-verbose-block AuthResolver() construction with direct use of the auth_resolver parameter already available in scope.
In _validate_package_exists, the URL for git ls-remote was built via _build_repo_url() without a token for GHES and ADO hosts. Since the downloader constructor no longer resolves credentials eagerly (to prevent duplicate auth popups), self.github_token / self.ado_token are now env-only. Credential-helper-only users on GHES/ADO would therefore receive a tokenless ls-remote URL and see an OS credential prompt -- reintroducing exactly the problem this PR targets. Fix: call auth_resolver.resolve_for_dep(dep_ref) for non-generic (GHES/ADO) hosts before building the URL, and pass the resolved token to _build_repo_url(token=...). Generic hosts are excluded because they rely on git credential helpers through the relaxed validate_env, not on APM-managed auth.
…per users The error handler in _clone_with_fallback used self.has_github_token to determine whether to show an 'authentication not configured' message. Since the constructor no longer calls auth_resolver.resolve() eagerly, self.has_github_token reflects env-var tokens only. Credential-helper- only users (gh auth login, macOS Keychain, etc.) have has_github_token=False even when credentials are available via helper, causing a misleading 'set up authentication' error when the actual cause is a permissions or repo-not-found issue. Switch to has_token, which is derived from dep_ctx.token (the result of per-dependency resolve_for_dep(), including credential-helper tokens). When has_token is True but clone still fails, the error correctly falls through to the generic 'check permissions' message. When has_token is False (truly no auth configured), the auth setup guidance is shown.
… guard AuthResolver has no optional dependencies (stdlib + internal utils only). Add comments explaining it must be imported unconditionally, not inside the APM_DEPS_AVAILABLE try/except block: if an optional dep like GitPython is missing, a gated import would produce a NameError inside install() before the graceful APM_DEPS_AVAILABLE guard can produce a useful error.
Add a comment explaining why the lock is held for the full duration of credential resolution, not just for the cache check/write. Key points: - Prevents duplicate OS credential-helper popups under parallel downloads - First caller fills cache; subsequent same-key calls are O(1) hits - Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s) - No deadlock risk: single lock, never nested
…ownloader The field was assigned in _setup_git_environment() but never read anywhere in the codebase. Remove it to eliminate dead code and reduce confusion about its purpose.
|
I've reviewed & fixed findings using Sonnet 4.6, did a review, and also used Codex 5.3 for cross-review. |
…ver-prevent-duplicate-popups # Conflicts: # src/apm_cli/commands/install.py
…ver-prevent-duplicate-popups # Conflicts: # src/apm_cli/commands/install.py
|
@danielmeppiel in case you missed last updates - PR looks good to me! |
#394 which was meant to be a continuation of #389, but the second commit was missed from the pull request. This pull request is a reimplementation of this change.
Problem
When running
apm installagainst a plugin stored in a private Enterprise GitHub organization, APM triggered one authentication popup per network operation. For a plugin that contains a single skill file and a single MCP server definition, the number of credential-helper invocations could reach three in the same command invocation:git ls-remote(or GitHub API) to confirm the plugin repository exists and is accessible.plugin.jsonfrom the repository.SKILL.mdasset.Each of those calls created its own
AuthResolverinstance with an empty cache, so the OS credential helper was invoked fresh every time - producing three separate authentication popups in rapid succession. Installing 10 similar plugins would show 30 GitHub authentication popups.Minimal reproducible scenario - an organization plugin
corp-org/acme-docs-plugin:{ "name": "acme-docs", "version": "1.0.0", "description": "Internal documentation assistant for Copilot.", "repository": "corp-org/acme-docs-plugin", "skills": ["./skills/acme-docs"], "mcpServers": { "com.corp-org/acme-docs": { "command": "npx", "args": ["--registry", "https://registry.npmjs.org", "@corp-org/acme-docs-assistant"] } } }Running
apm install corp-org/acme-docs-pluginagainst a GitHub Enterprise host producing:Root cause
Two independent issues compounded each other:
No resolver sharing -
_validate_package_existsand_install_apm_dependencieseach instantiated their ownAuthResolver(), so no cached token was ever reused across calls within the sameapm installrun.Lock inversion in
AuthResolver.resolve()- the cache lookup and the cache fill were in separatewith self._lockblocks, so two concurrent threads resolving the same(host, org)key would both miss the cache and each trigger their own credential-helper lookup before either result was written back.Fix
AuthResolverinstance is created at the top of theinstallcommand and passed down through_validate_and_add_packages_to_apm_yml,_validate_package_exists, and_install_apm_dependencies. All three network operations now share the same cache.AuthResolver.resolve()has been tightened to a singlewith self._lockblock: the cache check, the credential resolution, and the cache write all happen inside one critical section, eliminating the race.With the fix, the same
apm install corp-org/acme-docs-pluginscenario produces exactly one popup; all subsequent calls hit the in-memory cache.Tests
AuthResolverpassed toGitHubPackageDownloaderhas its cached token reused across multiple download calls.