diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ed05e0810..1bca79d97a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- The anonymous-first `github.com` HTTPS attempt now inherits `http.sslBackend`/`http.sslCAInfo` from the real (non-isolated) git config (including the credentialed retry and `--auth-first` bypass), and `--auth-first` / `APM_GITHUB_AUTH_FIRST` / `apm config set github-auth-first true` skip that attempt entirely as an escape hatch for networks behind a TLS-inspecting proxy whose intercepting CA chain has no revocation info at all. (#2545)
+
## [0.31.0] - 2026-09-15
### Added
diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md
index a834ba9724..c795b3d07e 100644
--- a/docs/src/content/docs/getting-started/authentication.md
+++ b/docs/src/content/docs/getting-started/authentication.md
@@ -14,7 +14,9 @@ Installing or updating the CLI queries public release metadata authenticated-fir
Public `github.com` packages need no token configuration. APM tries HTTPS repository operations anonymously before resolving credentials.
-The first attempt has no authorization header, GitHub token environment variable, or active Git credential helper. APM preserves caller-supplied Git config, including URL rewrites, CA settings, and non-credential HTTP headers, while removing credential-bearing headers and helpers. A 401, 403, 404, or equivalent Git authentication failure unlocks the credential chain below; DNS, TLS, timeout, and GitHub throttle failures do not.
+The first attempt has no authorization header, GitHub token environment variable, or active Git credential helper. APM preserves caller-supplied Git config, including URL rewrites, CA settings, and non-credential HTTP headers, while removing credential-bearing headers and helpers. It also inherits `http.sslBackend`/`http.sslCAInfo` from your real (non-isolated) Git config, so a corporate TLS trust store or `sslBackend` setting still applies to the anonymous attempt. A 401, 403, 404, or equivalent Git authentication failure unlocks the credential chain below; DNS, TLS, timeout, and GitHub throttle failures do not.
+
+If your network sits behind a TLS-inspecting proxy whose intercepting CA chain carries no revocation info at all, the anonymous attempt's isolated environment can still fail a TLS handshake even with that inheritance. `--auth-first` / `APM_GITHUB_AUTH_FIRST=1` / `apm config set github-auth-first true` skip the anonymous attempt entirely for exact-host `github.com` and go straight to your resolved credentials/environment. See [TLS-inspecting proxy blocks the anonymous attempt](#tls-inspecting-proxy-blocks-the-anonymous-attempt).
APM resolves ordinary tokens per `(host, port, org)` scope. When a private `github.com` fallback asks the credential helper for a repository path, that path also scopes the cache entry. APM then walks a **host-class-specific** chain until it finds a token:
@@ -130,6 +132,7 @@ For Copilot/runtime token variables (`GITHUB_COPILOT_PAT`, etc.), see [Authentic
| `GITHUB_HOST` | Default host for bare package names (e.g., GHES hostname) |
| `ADO_HOST` | One on-prem Azure DevOps Server FQDN, without a port or path (e.g., `ado.company.com`) |
| `APM_ADO_HOSTS` | Comma-separated list of on-prem ADO Server FQDNs, without ports or paths |
+| `APM_GITHUB_AUTH_FIRST` | `1`/`true` skips the anonymous-first HTTPS attempt for exact-host `github.com` (also: `--auth-first`, `apm config set github-auth-first true`). See [TLS-inspecting proxy blocks the anonymous attempt](#tls-inspecting-proxy-blocks-the-anonymous-attempt) |
## Multi-org setup
@@ -513,6 +516,24 @@ APM tries unauthenticated access first for public repos to conserve rate limits
export GITHUB_TOKEN=ghp_any_valid_token
```
+### TLS-inspecting proxy blocks the anonymous attempt
+
+APM's anonymous-first attempt for `github.com` inherits `http.sslBackend`/`http.sslCAInfo` from your real (non-isolated) Git config, so a corporate TLS trust store usually works out of the box. If your network sits behind a TLS-inspecting proxy (e.g. Netskope) whose intercepting CA chain has **no CRL Distribution Point and no Authority Information Access (OCSP) extension at all** -- not just unreachable, genuinely absent -- the isolated attempt may still fail the TLS handshake even with that inheritance, while a plain `git clone` on the same machine succeeds because it also uses your credential helper.
+
+Symptoms: `apm install` fails with a network/TLS-classified error (e.g. `CRYPT_E_NO_REVOCATION_CHECK` on Windows, or a hang) rather than an authentication error, and `--allow-protocol-fallback` jumps straight to SSH, which may also be blocked.
+
+Skip the anonymous attempt and use your resolved credentials/environment from the first HTTPS attempt:
+
+```bash
+apm install --auth-first
+# or
+export APM_GITHUB_AUTH_FIRST=1
+# or, to always do this on this machine
+apm config set github-auth-first true
+```
+
+This trades the anonymous-first credential-isolation property for environment-inheriting behavior; APM prints a one-time `[!]` warning when it is active. `--auth-first` only affects exact-host `github.com`; GHE Cloud, GHES, ADO, GitLab, and generic hosts already resolve credentials first.
+
### SSO-protected organizations
Authorize your PAT for SSO at [github.com/settings/tokens](https://github.com/settings/tokens) — click **Configure SSO** next to the token.
@@ -541,10 +562,12 @@ The full resolution and fallback flow (simplified):
flowchart TD
A[Dependency Reference] --> HC{Host class?}
- HC -->|github.com HTTPS| ANON[Anonymous attempt
tokens and helpers fenced]
+ HC -->|github.com HTTPS| AF{auth-first opt-out?}
+ AF -->|"No: default"| ANON["Anonymous attempt
tokens and helpers fenced
real http.sslBackend/sslCAInfo inherited"]
ANON -->|Success| L[Success]
ANON -->|401 / 403 / 404
or Git auth failure| B{Per-org env var?}
ANON -->|DNS / TLS / timeout
or rate limit| Q[Surface non-auth failure]
+ AF -->|"Yes: --auth-first / APM_GITHUB_AUTH_FIRST"| B
HC -->|GHE Cloud / GHES| B
B -->|GITHUB_APM_PAT_ORG| C[Use per-org token]
diff --git a/docs/src/content/docs/reference/cli/config.md b/docs/src/content/docs/reference/cli/config.md
index 3709d06f80..35584a0a6d 100644
--- a/docs/src/content/docs/reference/cli/config.md
+++ b/docs/src/content/docs/reference/cli/config.md
@@ -58,7 +58,7 @@ Write `KEY` to `~/.apm/config.json`. Validates the value before writing:
### `apm config unset KEY`
-Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported unset keys: `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `copilot-cowork-skills-dir`, `prefer-ssh`, `allow-protocol-fallback`, `audit-on-install`, `external..{llm,args}`, `mcp-registry-url`, and `registry..{url,token,default}`. After unsetting a key the effective value falls back to the environment variable, then the built-in default. Other boolean keys are reset by `set`-ing them to their default.
+Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported unset keys: `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `copilot-cowork-skills-dir`, `prefer-ssh`, `allow-protocol-fallback`, `github-auth-first`, `audit-on-install`, `external..{llm,args}`, `mcp-registry-url`, and `registry..{url,token,default}`. After unsetting a key the effective value falls back to the environment variable, then the built-in default. Other boolean keys are reset by `set`-ing them to their default.
## Configuration keys
@@ -71,6 +71,7 @@ Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported u
| `temp-dir` | path | system temp | Directory used for clone and download operations. Useful when the OS temp directory is locked down (for example, corporate Windows endpoints rejecting `%TEMP%` with `[WinError 5]`). |
| `allow-protocol-fallback` | boolean | `false` | Enable the legacy cross-protocol fallback chain. When true, APM retries a failed clone with the opposite protocol (SSH -> HTTPS or HTTPS -> SSH). Equivalent to `--allow-protocol-fallback` or `APM_ALLOW_PROTOCOL_FALLBACK=1`. |
| `prefer-ssh` | boolean | `false` | Prefer SSH transport for shorthand (`owner/repo`) dependencies. Equivalent to `--ssh` or `APM_GIT_PROTOCOL=ssh`. |
+| `github-auth-first` | boolean | `false` | Skip the anonymous-first HTTPS attempt for exact-host `github.com` and resolve credentials/environment immediately. Escape hatch for TLS-inspecting proxies whose intercepting CA chain has no revocation info. Equivalent to `--auth-first` or `APM_GITHUB_AUTH_FIRST=1`. |
| `copilot-cowork-skills-dir` | absolute path | auto-detected | Override the resolved Cowork OneDrive skills directory. Requires the `copilot-cowork` experimental flag for `set`. |
| `audit-on-install` | enum | `off` | Default content-audit mode for `apm install`: `off` / `warn` / `block`. `warn` records findings in the install summary; `block` halts on critical findings. Overridable per-install with `--audit` / `--no-audit`; an org policy `security.audit.on_install` floor can raise it. Requires the `external-scanners` experimental flag for `set`. |
| `external..llm` | boolean | unset | Opt a SARIF scanner into LLM-powered analysis (`` validated against supported scanners). SkillSpector default is offline. LLM mode makes outbound API calls and needs `OPENAI_API_KEY` or `NVIDIA_INFERENCE_KEY`. Overridable per-run with `--external-llm` / `--no-external-llm`. Requires the `external-scanners` experimental flag. |
@@ -100,13 +101,21 @@ only. Plaintext `http://` needs `MCP_REGISTRY_ALLOW_HTTP=1` when it arrives from
the environment or from `apm.yml`; a URL you persisted with `apm config set`
needs no opt-in, since setting it is already the explicit choice.
-`allow-protocol-fallback` and `prefer-ssh` follow the layered transport precedence:
+`allow-protocol-fallback` and `prefer-ssh` (transport selection) follow the layered precedence:
1. CLI flag (`--allow-protocol-fallback`, `--ssh`) -- highest priority
2. Environment variable (`APM_ALLOW_PROTOCOL_FALLBACK=1`, `APM_GIT_PROTOCOL=ssh`)
3. Value in `~/.apm/config.json` (`apm config set ...`)
4. Built-in default (`false` / no preference)
+`github-auth-first` follows the same layered shape, but it is a credential-order
+setting, not a transport selector -- see [`apm install`'s `--auth-first`](../install/#cache-and-network):
+
+1. CLI flag (`--auth-first`) -- highest priority
+2. Environment variable (`APM_GITHUB_AUTH_FIRST=1`)
+3. Value in `~/.apm/config.json` (`apm config set github-auth-first true`)
+4. Built-in default (`false`: anonymous-first for exact-host `github.com`)
+
Registry tokens are resolved as:
1. `APM_REGISTRY_TOKEN_` environment variable (uppercase name, `-`/`.` -> `_`)
@@ -185,6 +194,15 @@ apm config set allow-protocol-fallback true
apm config get allow-protocol-fallback
```
+Persist the github.com auth-first opt-out (for networks behind a TLS-inspecting proxy whose intercepting CA chain has no revocation info):
+
+```bash
+apm config set github-auth-first true
+apm config get github-auth-first
+# Remove the persisted preference:
+apm config unset github-auth-first
+```
+
Pin a writable temp directory on Windows:
```bash
@@ -248,13 +266,13 @@ See [External scanners](../../../integrations/external-scanners/).
- **Format:** JSON object, one entry per stored key.
- **Created on first read** with `{"default_client": "vscode"}`. Hand-editing is supported but `apm config set` is preferred -- it validates input and normalizes paths.
-Internal JSON keys use snake_case (`auto_integrate`, `install_target`, `self_update_channel`, `self_update_install_dir`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `copilot_cowork_skills_dir`); CLI keys use kebab-case or dotted namespaces (the CLI `target` key is stored as `install_target`). The CLI translates between the two.
+Internal JSON keys use snake_case (`auto_integrate`, `install_target`, `self_update_channel`, `self_update_install_dir`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `github_auth_first`, `copilot_cowork_skills_dir`); CLI keys use kebab-case or dotted namespaces (the CLI `target` key is stored as `install_target`). The CLI translates between the two.
## Related
-- [`apm install`](../install/) -- consumes `target`, `temp-dir`, and `allow-protocol-fallback` / `prefer-ssh`.
+- [`apm install`](../install/) -- consumes `target`, `temp-dir`, and `allow-protocol-fallback` / `prefer-ssh` / `github-auth-first`.
- [`apm self-update`](../self-update/) -- consumes `self-update.channel` and `self-update.install-dir`.
- [`apm compile`](../compile/) -- affected by `auto-integrate`.
- [`apm experimental`](../experimental/) -- gates `copilot-cowork-skills-dir` and `registry.*` keys.
-- [Environment variables](../../environment-variables/) -- `APM_ALLOW_PROTOCOL_FALLBACK`, `APM_GIT_PROTOCOL` are the env-var equivalents of the transport keys.
+- [Environment variables](../../environment-variables/) -- `APM_ALLOW_PROTOCOL_FALLBACK` and `APM_GIT_PROTOCOL` are the env-var equivalents of the transport keys; `APM_GITHUB_AUTH_FIRST` is the env-var equivalent of the credential-order `github-auth-first` key.
- [Registries](../../../guides/registries/) -- full private registry setup guide.
diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md
index 7b092b560d..0c0280aba3 100644
--- a/docs/src/content/docs/reference/cli/install.md
+++ b/docs/src/content/docs/reference/cli/install.md
@@ -84,8 +84,9 @@ you use the generated registration.
| `--ssh` | off | Prefer SSH transport for shorthand (`owner/repo`) deps. Mutually exclusive with `--https`. |
| `--https` | off | Prefer HTTPS transport for shorthand deps. Mutually exclusive with `--ssh`. |
| `--allow-protocol-fallback` | off | Restore the legacy permissive HTTPS<->SSH fallback chain. Env: `APM_ALLOW_PROTOCOL_FALLBACK=1`. |
+| `--auth-first` | off | Skip the anonymous-first HTTPS attempt for exact-host `github.com` and resolve credentials/environment immediately. Escape hatch for TLS-inspecting proxies whose intercepting CA chain has no revocation info. Env: `APM_GITHUB_AUTH_FIRST=1`. |
-Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initial transport for shorthand deps; `APM_ALLOW_PROTOCOL_FALLBACK=1` mirrors `--allow-protocol-fallback`.
+Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initial transport for shorthand deps; `APM_ALLOW_PROTOCOL_FALLBACK=1` mirrors `--allow-protocol-fallback`. Credential-order env var: `APM_GITHUB_AUTH_FIRST=1` mirrors `--auth-first` (not a transport selector -- see [Authentication: TLS-inspecting proxy blocks the anonymous attempt](../../getting-started/authentication/#tls-inspecting-proxy-blocks-the-anonymous-attempt)).
### Skill subset
diff --git a/docs/src/content/docs/reference/environment-variables.md b/docs/src/content/docs/reference/environment-variables.md
index fe5056bb9b..7052f5b7ba 100644
--- a/docs/src/content/docs/reference/environment-variables.md
+++ b/docs/src/content/docs/reference/environment-variables.md
@@ -33,14 +33,15 @@ PAT / bearer credentials APM reads when cloning packages, calling host APIs, or
| `GIT_SSH_COMMAND` | Standard git SSH command override. APM reads it before composing its own SSH env. | unset | If you set it, APM preserves your value. |
| `APM_GIT_CREDENTIAL_TIMEOUT` | Seconds to wait for a `git credential fill` response. | implementation default | Integer-like string; invalid values are ignored. |
-## Transport and protocol
+## Transport and credential order
-Controls how APM clones packages and enumerates refs on Git hosts. These settings can also be persisted via [`apm config set`](../cli/config/) to avoid repeating flags or environment-variable exports.
+Controls how APM clones packages and enumerates refs on Git hosts: `APM_GIT_PROTOCOL` and `APM_ALLOW_PROTOCOL_FALLBACK` select the transport scheme, while `APM_GITHUB_AUTH_FIRST` changes credential order for exact-host `github.com` without affecting transport. These settings can also be persisted via [`apm config set`](../cli/config/) to avoid repeating flags or environment-variable exports.
| Variable | Purpose | Default | Notes |
|---|---|---|---|
| `APM_GIT_PROTOCOL` | Preferred Git protocol for cloning and ref enumeration on shorthand (`owner/repo`) dependencies. Accepted values: `ssh`, `https`. | unset | Equivalent to `--ssh` / `--https` flag. Resolution: CLI flag → env var → `prefer-ssh` key in `~/.apm/config.json` → git `insteadOf` rules → HTTPS. |
| `APM_ALLOW_PROTOCOL_FALLBACK` | Set to `1` (or `true`/`yes`/`on`) to enable the legacy cross-protocol fallback chain. When enabled, a failed clone is retried with the opposite protocol. | unset | Equivalent to `--allow-protocol-fallback`. Resolution: CLI flag → env var → `allow-protocol-fallback` key in `~/.apm/config.json` → `false`. |
+| `APM_GITHUB_AUTH_FIRST` | Set to `1` (or `true`/`yes`/`on`) to skip the anonymous-first HTTPS attempt for exact-host `github.com` and resolve credentials/environment immediately. Escape hatch for TLS-inspecting proxies whose intercepting CA chain has no revocation info. | unset | Equivalent to `--auth-first`. Resolution: CLI flag → env var → `github-auth-first` key in `~/.apm/config.json` → `false`. See [Authentication: TLS-inspecting proxy blocks the anonymous attempt](../getting-started/authentication/#tls-inspecting-proxy-blocks-the-anonymous-attempt). |
## TLS trust
diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md
index 9086104447..d8b1d2af4f 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md
@@ -6,7 +6,9 @@ CLI bootstrap/update metadata recovery is separate from package authentication.
## Token precedence chain
-For public `github.com` HTTPS repositories, APM makes one anonymous attempt before checking any token source. The attempt removes GitHub token variables, credential-bearing HTTP headers, and credential helpers while preserving CA settings, safe URL rewrites, non-credential HTTP headers, and `credential.interactive=never`.
+For public `github.com` HTTPS repositories, APM makes one anonymous attempt before checking any token source. The attempt removes GitHub token variables, credential-bearing HTTP headers, and credential helpers while preserving CA settings, safe URL rewrites, non-credential HTTP headers, and `credential.interactive=never`. It also inherits `http.sslBackend`/`http.sslCAInfo` from the real (non-isolated) git config, so a corporate TLS trust store or `sslBackend` setting still applies.
+
+If a network sits behind a TLS-inspecting proxy whose intercepting CA chain carries no revocation info at all, the isolated attempt can still fail the TLS handshake even with that inheritance. `--auth-first` / `APM_GITHUB_AUTH_FIRST=1` / `apm config set github-auth-first true` skip the anonymous attempt entirely for exact-host `github.com` and resolve credentials/environment immediately, emitting a one-time `[!]` warning when active. See [Authentication: TLS-inspecting proxy blocks the anonymous attempt](https://microsoft.github.io/apm/getting-started/authentication/#tls-inspecting-proxy-blocks-the-anonymous-attempt).
Only HTTP 401, 403, 404, or an equivalent Git authentication failure unlocks the fallback chain below. DNS, TLS, timeout, and GitHub throttle failures do not prompt for credentials. Ordinary credentials are cached per `(host, port, org)` for the process. A private `github.com` helper fallback adds the repository path to that scope, so later phases reuse it without applying that credential to another repository. APM never writes the credential into persistent cache keys or stored remote URLs.
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index 152afcf2f8..63967cf5c8 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -420,10 +420,10 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning,
| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm config` | Show current configuration | -- |
-| `apm config get [KEY]` | Get one config value; with no key, prints the user config summary (effective values for core user-settable keys; omits noise-reduction defaults/unset optional values until configured) (`auto-integrate`, `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- |
+| `apm config get [KEY]` | Get one config value; with no key, prints the user config summary (effective values for core user-settable keys; omits noise-reduction defaults/unset optional values until configured) (`auto-integrate`, `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `github-auth-first`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- |
| `apm config list` | Show the default user config listing; this aliases `apm config get` with no key and omits noisy defaults/unset optional values until configured | -- |
-| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- |
-| `apm config unset KEY` | Remove a stored config value (`target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- |
+| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `github-auth-first`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- |
+| `apm config unset KEY` | Remove a stored config value (`target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `github-auth-first`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- |
| `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying or deleting files in agent targets. Existing deployed-file provenance stays recorded while those bytes remain on disk; the next normal install performs any deferred prune. Mirrors `cargo generate-lockfile` / `pnpm lock`. | `--update` re-resolve to latest upstream SHAs without accepting stale local bare refs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` |
| `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. Diagnostics and startup notices use stderr so stdout stays machine-readable. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) |
| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against authenticated upstream refs, never stale bare-cache refs, prints a structured plan (added/updated/removed/unchanged), and prompts before changing refs (default `[y/N]`). Remote-resolution failure aborts without substituting cached ref state. Full-SHA pins move only to an eligible stable annotated semver tag. If none exists, APM keeps the current SHA, emits one summary warning, and continues unrelated updates; malformed or ambiguous tag records remain fatal before writes. Accepted pin updates are annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries when the declared target set contracts. If the lock expects dependencies but `apm_modules/` is empty, an unchanged update restores the cache from the same refs without prompting or rewriting the manifest/lock; `--dry-run` remains read-only. `--force` changes collision/security handling, not freshness; use it only after independent verification. Strict superset of the deprecated `apm deps update`. Skips the ref-change prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) |
@@ -433,6 +433,8 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning,
`apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config list` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get ` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI.
+`apm config set github-auth-first true` (or `--auth-first` / `APM_GITHUB_AUTH_FIRST=1`) skips the anonymous-first HTTPS attempt for exact-host `github.com`, for networks behind a TLS-inspecting proxy whose intercepting CA chain has no revocation info at all -- resolution order and the `false`-default noise suppression mirror `allow-protocol-fallback` above. `apm config unset github-auth-first` removes the persisted value. Unlike the transport keys, this is a credential-order decision, not a transport selector: it does not conflict with `--ssh` / `--https` / `--allow-protocol-fallback`, and it is rejected (like them) with `--mcp`, reported as a separate credential-order error rather than lumped into the transport-selection-flags message.
+
`apm config set target ` persists a default install target (single token or comma-separated list) for `apm install` when both `--target` and `apm.yml target(s)` are absent. `apm config unset target` removes this fallback.
Self-update preferences: `self-update.channel` accepts `stable` or `prerelease`; `self-update.install-dir` is optional. On Unix, a set directory must match the existing installation, while leaving it unset preserves the detected installation. Windows keeps its installer destination/default behavior. `VERSION` pins a release; `APM_SELF_UPDATE_CHANNEL` and `APM_INSTALL_DIR` override config. Both channels pass a normalized `v` to the installer and GitHub/GHES script URL. `APM_INSTALLER_BASE_URL` stays authoritative, receiving only the script name. Config excludes credentials, mirror URLs, commands, and installer arguments.
diff --git a/src/apm_cli/commands/config.py b/src/apm_cli/commands/config.py
index ddb4ccc308..86a31d376c 100644
--- a/src/apm_cli/commands/config.py
+++ b/src/apm_cli/commands/config.py
@@ -28,6 +28,7 @@
"copilot_cowork_skills_dir": "copilot-cowork-skills-dir",
"allow_protocol_fallback": "allow-protocol-fallback",
"prefer_ssh": "prefer-ssh",
+ "github_auth_first": "github-auth-first",
}
@@ -50,23 +51,35 @@ def _parse_bool_value(value: str) -> bool:
def _get_config_setters():
"""Return config setters keyed by CLI option name."""
- from ..config import set_allow_protocol_fallback, set_auto_integrate, set_prefer_ssh
+ from ..config import (
+ set_allow_protocol_fallback,
+ set_auto_integrate,
+ set_github_auth_first,
+ set_prefer_ssh,
+ )
return {
"auto-integrate": (set_auto_integrate, "Auto-integration"),
"allow-protocol-fallback": (set_allow_protocol_fallback, "Protocol fallback"),
"prefer-ssh": (set_prefer_ssh, "SSH transport preference"),
+ "github-auth-first": (set_github_auth_first, "GitHub auth-first"),
}
def _get_config_getters():
"""Return config getters keyed by CLI option name."""
- from ..config import get_allow_protocol_fallback, get_auto_integrate, get_prefer_ssh
+ from ..config import (
+ get_allow_protocol_fallback,
+ get_auto_integrate,
+ get_github_auth_first,
+ get_prefer_ssh,
+ )
return {
"auto-integrate": get_auto_integrate,
"allow-protocol-fallback": get_allow_protocol_fallback,
"prefer-ssh": get_prefer_ssh,
+ "github-auth-first": get_github_auth_first,
}
@@ -83,6 +96,7 @@ def _valid_config_keys() -> str:
"temp-dir",
"allow-protocol-fallback",
"prefer-ssh",
+ "github-auth-first",
]
if is_enabled("external_scanners"):
keys.append("audit-on-install")
@@ -102,6 +116,7 @@ def _show_all_user_config(logger: CommandLogger) -> None:
from ..config import (
get_allow_protocol_fallback,
get_auto_integrate,
+ get_github_auth_first,
get_install_target,
get_mcp_registry_url,
get_prefer_ssh,
@@ -130,6 +145,8 @@ def _show_all_user_config(logger: CommandLogger) -> None:
click.echo(" allow-protocol-fallback: true")
if get_prefer_ssh():
click.echo(" prefer-ssh: true")
+ if get_github_auth_first():
+ click.echo(" github-auth-first: true")
from ..core.experimental import is_enabled
@@ -249,6 +266,7 @@ def config(ctx):
config_table.add_row("Global", "APM CLI Version", get_version())
from ..config import get_allow_protocol_fallback as _get_apf
+ from ..config import get_github_auth_first as _get_gaf
from ..config import get_prefer_ssh as _get_prefer_ssh_cfg
from ..config import get_self_update_channel as _get_self_update_channel_cfg
from ..config import get_self_update_install_dir as _get_self_update_install_dir_cfg
@@ -272,10 +290,13 @@ def config(ctx):
# false-default rows add noise for users who never configured them.
_apf = _get_apf()
_prefer_ssh = _get_prefer_ssh_cfg()
+ _gaf = _get_gaf()
if _apf:
config_table.add_row("", "Allow Protocol Fallback", "true")
if _prefer_ssh:
config_table.add_row("", "Prefer SSH Transport", "true")
+ if _gaf:
+ config_table.add_row("", "GitHub Auth-first", "true")
from ..core.experimental import is_enabled as _is_enabled
@@ -311,6 +332,7 @@ def config(ctx):
click.echo(f" APM CLI Version: {get_version()}")
from ..config import get_allow_protocol_fallback as _get_apf_fb
+ from ..config import get_github_auth_first as _get_gaf_fb
from ..config import get_prefer_ssh as _get_prefer_ssh_fb
from ..config import get_self_update_channel as _get_self_update_channel_fb
from ..config import get_self_update_install_dir as _get_self_update_install_dir_fb
@@ -328,6 +350,7 @@ def config(ctx):
click.echo(f" allow-protocol-fallback: {str(_get_apf_fb()).lower()}")
click.echo(f" prefer-ssh: {str(_get_prefer_ssh_fb()).lower()}")
+ click.echo(f" github-auth-first: {str(_get_gaf_fb()).lower()}")
from ..core.experimental import is_enabled as _is_enabled_fb
@@ -831,6 +854,13 @@ def unset(key):
logger.success("SSH transport preference removed (will use env var or default)")
return
+ if key == "github-auth-first":
+ from ..config import unset_github_auth_first
+
+ unset_github_auth_first()
+ logger.success("GitHub auth-first preference removed (will use env var or default)")
+ return
+
if key == "copilot-cowork-skills-dir":
from ..config import unset_copilot_cowork_skills_dir
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 84b5a9efad..ca582fbbfc 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -973,6 +973,13 @@ def _handle_mcp_install( # noqa: PLR0913
default=False,
help="Restore the legacy permissive cross-protocol fallback chain (escape hatch for migrating users; also: APM_ALLOW_PROTOCOL_FALLBACK=1). Caveat: fallback reuses the same port across schemes; on servers that use different SSH and HTTPS ports, omit this flag and pin the dependency with an explicit ssh:// or https:// URL.",
)
+@click.option(
+ "--auth-first",
+ "auth_first",
+ is_flag=True,
+ default=False,
+ help="Skip the anonymous-first HTTPS attempt for github.com and go straight to your resolved git credentials/environment (also: APM_GITHUB_AUTH_FIRST=1, apm config set github-auth-first true). Escape hatch for TLS-inspecting proxies whose intercepting CA chain has no revocation info, where the isolated anonymous attempt cannot complete a handshake even though your own git config can. Trade-off: forwards credentials on the first attempt instead of trying anonymously first.",
+)
@click.option(
"--mcp",
"mcp_name",
@@ -1139,6 +1146,7 @@ def install( # noqa: PLR0913
use_ssh,
use_https,
allow_protocol_fallback,
+ auth_first,
mcp_name,
transport,
url,
@@ -1286,6 +1294,7 @@ def install( # noqa: PLR0913
"--ssh": use_ssh,
"--https": use_https,
"--allow-protocol-fallback": allow_protocol_fallback,
+ "--auth-first": auth_first,
"--mcp": mcp_name,
"--registry": registry_url,
"--skill": bool(skill_names),
@@ -1369,6 +1378,7 @@ def install( # noqa: PLR0913
only=only,
update=update,
any_transport_flag=use_ssh or use_https or allow_protocol_fallback,
+ auth_first=auth_first,
registry_url=validated_registry_url,
)
# Normalize --skill: '*' means all (same as absent). Reject with --mcp.
@@ -1459,7 +1469,9 @@ def install( # noqa: PLR0913
# Create shared auth resolver for all downloads in this CLI invocation
# to ensure credentials are cached and reused (prevents duplicate auth popups)
- auth_resolver = AuthResolver()
+ # issue #2545: --auth-first is CLI-scoped here; AuthResolver falls back to
+ # APM_GITHUB_AUTH_FIRST / apm config github-auth-first when not passed.
+ auth_resolver = AuthResolver(github_auth_first=auth_first)
# F2/F3 #856: thread the InstallLogger into AuthResolver so the verbose
# auth-source line and the deferred stale-PAT [!] warning route through
# CommandLogger / DiagnosticCollector instead of stderr/inline writes.
diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py
index bf62234e17..fd27b1be2f 100644
--- a/src/apm_cli/config.py
+++ b/src/apm_cli/config.py
@@ -10,6 +10,7 @@
# ---------------------------------------------------------------------------
_ENV_ALLOW_PROTOCOL_FALLBACK = "APM_ALLOW_PROTOCOL_FALLBACK"
_ENV_GIT_PROTOCOL = "APM_GIT_PROTOCOL"
+_ENV_GITHUB_AUTH_FIRST = "APM_GITHUB_AUTH_FIRST"
CONFIG_DIR = os.path.expanduser("~/.apm")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
@@ -411,8 +412,11 @@ def unset_prefer_ssh() -> None:
_unset_config_key("prefer_ssh")
-def _parse_allow_protocol_fallback_env(raw: str | None) -> bool | None:
- """Parse ``APM_ALLOW_PROTOCOL_FALLBACK`` as a tri-state value.
+def _parse_tristate_bool_env(raw: str | None) -> bool | None:
+ """Parse a tri-state boolean environment variable value.
+
+ Shared by every ``APM_*`` boolean env var (``APM_ALLOW_PROTOCOL_FALLBACK``,
+ ``APM_GITHUB_AUTH_FIRST``, ...) so the accepted spellings stay in one place.
Args:
raw: Raw environment variable value, or ``None`` when unset.
@@ -434,6 +438,11 @@ def _parse_allow_protocol_fallback_env(raw: str | None) -> bool | None:
return None
+def _parse_allow_protocol_fallback_env(raw: str | None) -> bool | None:
+ """Parse ``APM_ALLOW_PROTOCOL_FALLBACK`` as a tri-state value."""
+ return _parse_tristate_bool_env(raw)
+
+
def get_apm_allow_protocol_fallback(
*,
bootstrap: bool | None = None,
@@ -458,6 +467,82 @@ def get_apm_allow_protocol_fallback(
return get_allow_protocol_fallback(create_config=should_create)
+# ---------------------------------------------------------------------------
+# GitHub anonymous-first opt-out (issue #2545)
+# ---------------------------------------------------------------------------
+
+
+def get_github_auth_first(
+ *,
+ bootstrap: bool | None = None,
+ create_config: bool | None = None,
+) -> bool:
+ """Get the github-auth-first setting.
+
+ Args:
+ bootstrap: Legacy name for whether to create a missing user config file.
+ create_config: When false, do not create a missing user config file.
+
+ Returns:
+ bool: Whether exact-host github.com HTTPS attempts skip the
+ anonymous-first attempt and go straight to credentialed/environment-
+ inheriting behavior (default: False).
+ """
+ return get_config(
+ create=_resolve_create_config(bootstrap=bootstrap, create_config=create_config)
+ ).get("github_auth_first", False)
+
+
+def set_github_auth_first(enabled: bool) -> None:
+ """Set the github-auth-first setting.
+
+ Args:
+ enabled: Whether to skip the anonymous-first HTTPS attempt for
+ exact-host github.com dependencies.
+ """
+ update_config({"github_auth_first": enabled})
+
+
+def unset_github_auth_first() -> None:
+ """Remove the ``github_auth_first`` key from the config file.
+
+ No-op if the key is not present. After this call
+ :func:`get_apm_github_auth_first` will fall through to
+ ``APM_GITHUB_AUTH_FIRST`` env var and then the built-in default (``False``).
+ """
+ _unset_config_key("github_auth_first")
+
+
+def _parse_github_auth_first_env(raw: str | None) -> bool | None:
+ """Parse ``APM_GITHUB_AUTH_FIRST`` as a tri-state value."""
+ return _parse_tristate_bool_env(raw)
+
+
+def get_apm_github_auth_first(
+ *,
+ bootstrap: bool | None = None,
+ create_config: bool | None = None,
+) -> bool:
+ """Return the effective github-auth-first flag.
+
+ Resolution order:
+ 1. ``APM_GITHUB_AUTH_FIRST`` environment variable
+ (``"1"``/``"true"``/``"yes"``/``"on"`` => True;
+ ``"0"``/``"false"``/``"no"``/``"off"`` => False)
+ 2. ``github_auth_first`` value from ``~/.apm/config.json``
+ 3. ``False`` (default: anonymous-first for exact-host github.com)
+
+ Returns:
+ ``True`` when github.com HTTPS attempts should skip the anonymous
+ attempt, otherwise ``False``.
+ """
+ env_value = _parse_github_auth_first_env(os.environ.get(_ENV_GITHUB_AUTH_FIRST))
+ if env_value is not None:
+ return env_value
+ should_create = _resolve_create_config(bootstrap=bootstrap, create_config=create_config)
+ return get_github_auth_first(create_config=should_create)
+
+
def get_apm_protocol_pref(
*,
bootstrap: bool | None = None,
diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py
index ad66efa0dc..3c9356c465 100644
--- a/src/apm_cli/core/auth.py
+++ b/src/apm_cli/core/auth.py
@@ -235,9 +235,17 @@ def __init__(
logger: object | None = None,
*,
allow_external_fallback: bool = True,
+ github_auth_first: bool = False,
):
self._token_manager = token_manager or GitHubTokenManager()
self._allow_external_fallback = allow_external_fallback
+ # issue #2545: CLI-scoped override for the anonymous-first opt-out.
+ # ``False`` (default) defers to ``get_apm_github_auth_first()``
+ # (env var > persisted config > False) inside
+ # ``uses_public_github_anonymous_first``; ``True`` short-circuits it
+ # for this resolver instance regardless of env/config.
+ self._github_auth_first_cli = github_auth_first
+ self._github_auth_first_warned = False
self._cache: dict[AuthCacheKey, AuthContext] = {}
self._lock = threading.Lock()
# F2/F3 #852: optional logger lets the install command route the
@@ -497,14 +505,55 @@ def uses_public_github_anonymous_first(
cache fetches, and clone execution cannot drift into separate host
checks. GitHub Enterprise, ADO, GitLab, and generic hosts retain their
existing authentication order.
+
+ Issue #2545: exact-host github.com normally answers ``True``, but the
+ ``--auth-first`` CLI flag / ``APM_GITHUB_AUTH_FIRST`` env var /
+ ``github_auth_first`` config (``get_apm_github_auth_first()``, in
+ that resolution order) opt out to ``False`` for users behind a
+ TLS-inspecting proxy whose intercepting CA chain has no revocation
+ info, where the isolated anonymous attempt cannot complete a TLS
+ handshake even though a plain ``git clone`` (which inherits the real
+ git/TLS config) succeeds.
"""
- return (
+ if (
self.classify_host(
host,
port=port,
host_type=host_type,
).kind
- == "github"
+ != "github"
+ ):
+ return False
+ from ..config import get_apm_github_auth_first
+
+ # create_config=False: this is a pure decision read, called from ~13
+ # sites per install; it must never create ~/.apm/config.json as a
+ # side effect (a prior `apm config set ...` already created it if
+ # the persisted key is meant to apply).
+ if self._github_auth_first_cli or get_apm_github_auth_first(create_config=False):
+ self.emit_github_auth_first_diagnostic()
+ return False
+ return True
+
+ def emit_github_auth_first_diagnostic(self) -> None:
+ """Emit a one-time [!] warning when the auth-first opt-out is active.
+
+ Issue #2545: the opt-out trades the anonymous-first credential-
+ isolation property for environment-inheriting behavior, so the
+ tradeoff is surfaced once per resolver instance rather than silently.
+ Mirrors :meth:`emit_stale_pat_diagnostic`'s logger-or-fallback shape.
+ """
+ with self._lock:
+ if self._github_auth_first_warned:
+ return
+ self._github_auth_first_warned = True
+ self._emit_warning_with_detail(
+ "github-auth-first is enabled: git credentials will be forwarded "
+ "on the first HTTPS attempt to github.com.",
+ "To restore the default, run 'apm config unset github-auth-first', "
+ "unset APM_GITHUB_AUTH_FIRST, or omit --auth-first.",
+ fallback_context="auth-first",
+ always_show_detail=True,
)
@staticmethod
@@ -640,19 +689,45 @@ def try_with_fallback(
port=port,
host_type=host_type,
)
- lazy_public_github = unauth_first and self.uses_public_github_anonymous_first(
+ github_anonymous_first_allowed = self.uses_public_github_anonymous_first(
host,
port=port,
host_type=host_type,
)
+ lazy_public_github = unauth_first and github_anonymous_first_allowed
+ # issue #2545: for exact-host github.com, the --auth-first opt-out
+ # (CLI flag / APM_GITHUB_AUTH_FIRST / persisted config) must skip
+ # the unauthenticated attempt entirely -- not merely fall back from
+ # the hardened "public github anonymous" env to a plain one -- or a
+ # caller's unauth_first=True (e.g. validation's rate-limit-saving
+ # probe) still tries token=None first and can hit the same TLS-
+ # inspecting-proxy failure --auth-first exists to route around.
+ # Every other host class (uses_public_github_anonymous_first is
+ # always False for them) keeps the historical unauth_first-only gate.
+ github_auth_first_opted_out = (
+ host_info.kind == "github" and not github_anonymous_first_allowed
+ )
+ attempt_unauthenticated = unauth_first and not github_auth_first_opted_out
auth_ctx: AuthContext | None = None
if not lazy_public_github:
+ # issue #2545: also scope the credential-fill lookup by path for
+ # the github-auth-first-opted-out case (mirrors the existing
+ # gitlab behavior) so GCM multi-account users still get correct
+ # per-URL account selection once the opt-out routes straight to
+ # credentials. Guarded on unauth_first so callers that already
+ # pass unauth_first=False (an explicit auth-first request,
+ # unrelated to this opt-out) keep their prior path=None behavior.
auth_ctx = self.resolve(
host,
org,
port=port,
host_type=host_type,
- path=path if host_info.kind == "gitlab" else None,
+ path=(
+ path
+ if host_info.kind == "gitlab"
+ or (unauth_first and github_auth_first_opted_out)
+ else None
+ ),
)
host_info = auth_ctx.host_info
unauth_env = (
@@ -706,18 +781,30 @@ def _try_credential_fallback(exc: Exception, ctx: AuthContext) -> T:
f"Token from {ctx.source} failed for {host_info.display_name}; "
"trying secondary credential sources"
)
+
+ def _secondary_credential_env(token: str, source: str) -> dict[str, str]:
+ """Build the env for a secondary (gh-cli / credential-fill)
+ token via the same TLS-aware path as the primary token
+ (issue #2545 follow-up: this fallback also reaches
+ github.com and must not lose the ambient TLS config)."""
+ return self.git_env_for_context(
+ AuthContext(
+ token=token,
+ source=source,
+ token_type="unknown",
+ host_info=host_info,
+ git_env={},
+ ),
+ base_env=base_env,
+ )
+
_log(f"trying gh auth token for {host_info.display_name}")
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)
if gh_token:
_log(f"gh auth token resolved a credential for {host_info.display_name}")
return operation(
gh_token,
- self._build_git_env(
- gh_token,
- scheme="basic",
- host_kind=host_info.kind,
- base_env=base_env,
- ),
+ _secondary_credential_env(gh_token, "gh-auth-token"),
)
path_suffix = f" (path={path})" if path else ""
_log(f"trying git credential fill for {host_info.display_name}{path_suffix}")
@@ -728,12 +815,7 @@ def _try_credential_fallback(exc: Exception, ctx: AuthContext) -> T:
_log(f"git credential fill resolved a credential for {host_info.display_name}")
return operation(
cred,
- self._build_git_env(
- cred,
- scheme="basic",
- host_kind=host_info.kind,
- base_env=base_env,
- ),
+ _secondary_credential_env(cred, "git-credential-fill"),
)
raise exc
@@ -824,7 +906,7 @@ def _try_ado_bearer_fallback(exc: Exception) -> T:
)
return _try_ado_bearer_fallback(exc)
- if unauth_first:
+ if attempt_unauthenticated:
# Validation path: save rate limits, EMU-safe
try:
_log(f"Trying unauthenticated access to {host_info.display_name}")
@@ -1317,7 +1399,17 @@ def build_public_github_anonymous_git_env(
*,
base_env: dict[str, str] | None = None,
) -> dict[str, str]:
- """Build a credential-free Git env for public github.com attempts."""
+ """Build a credential-free Git env for public github.com attempts.
+
+ Issue #2545: also inherits ``http.sslBackend``/``http.sslCAInfo``
+ from the caller's real (non-isolated) git config -- the two TLS
+ settings that let a plain ``git clone`` succeed behind a
+ TLS-inspecting proxy whose intercepting CA chain has no revocation
+ info, which the from-scratch isolated config below otherwise
+ discards along with ``credential.helper``. Only these two keys are
+ read; ``credential.helper`` stays forced empty regardless, so the
+ credential-isolation boundary is unaffected.
+ """
caller_env = os.environ if base_env is None else base_env
env = cls._build_git_env(
None,
@@ -1341,6 +1433,12 @@ def build_public_github_anonymous_git_env(
)
cls._append_git_config(env, "credential.helper", "")
cls._append_git_config(env, "http.extraheader", "")
+
+ from ..utils.git_env import real_git_tls_config
+
+ for key, value in real_git_tls_config().items():
+ if not cls._has_git_config_key(env, key):
+ cls._append_git_config(env, key, value)
return env
@classmethod
@@ -1369,22 +1467,57 @@ def _append_git_config(env: dict[str, str], key: str, value: str) -> None:
env[f"GIT_CONFIG_KEY_{count}"] = key
env[f"GIT_CONFIG_VALUE_{count}"] = value
+ @staticmethod
+ def _has_git_config_key(env: dict[str, str], key: str) -> bool:
+ """Return whether ``env`` already has an indexed entry for ``key``.
+
+ Case-insensitive, matching Git's own config-key normalization and
+ ``real_git_tls_config()``'s lower-cased keys. Used to make ambient
+ config inheritance (issue #2545) additive-only: it must fill a gap,
+ never override a value the caller (or an earlier build step)
+ already set explicitly -- indexed ``GIT_CONFIG_KEY_N`` entries are
+ last-one-wins in Git, so a blind append would otherwise let the
+ ambient value beat an explicit one appended earlier.
+ """
+ try:
+ count = max(0, int(env.get("GIT_CONFIG_COUNT", "0") or "0"))
+ except ValueError:
+ count = 0
+ normalized = key.lower()
+ return any(env.get(f"GIT_CONFIG_KEY_{i}", "").lower() == normalized for i in range(count))
+
@staticmethod
def git_env_for_context(
ctx: AuthContext,
*,
base_env: dict,
) -> dict:
- """Apply one resolved credential to a hardened Git base environment."""
+ """Apply one resolved credential to a hardened Git base environment.
+
+ Issue #2545: a credentialed exact-host github.com attempt (the
+ ordinary 401/403/404-triggered retry, or the ``--auth-first`` bypass
+ of the anonymous attempt) must not hit the same TLS-inspecting-proxy
+ failure the isolated hardened base env would otherwise reproduce --
+ mirrors :meth:`build_public_github_anonymous_git_env`. Scoped to
+ exact-host github.com only; GHE Cloud/GHES/ADO/GitLab/generic are
+ unaffected.
+ """
scheme = ctx.auth_scheme
if ctx.token and ctx.host_info.kind in {"github", "ghe_cloud", "ghes"}:
scheme = "github-basic"
- return AuthResolver._build_git_env(
+ env = AuthResolver._build_git_env(
ctx.token,
scheme=scheme,
host_kind=ctx.host_info.kind,
base_env=base_env,
)
+ if ctx.host_info.kind == "github":
+ from ..utils.git_env import real_git_tls_config
+
+ for key, value in real_git_tls_config().items():
+ if not AuthResolver._has_git_config_key(env, key):
+ AuthResolver._append_git_config(env, key, value)
+ return env
def hardened_git_base_env(self) -> dict:
"""Build the credential-free hardened base for Git subprocesses."""
@@ -1545,11 +1678,42 @@ def emit_stale_pat_diagnostic(self, host_display: str) -> None:
if host_display in self._stale_pat_warned_hosts:
return
self._stale_pat_warned_hosts.add(host_display)
- msg = f"ADO_APM_PAT was rejected for {host_display}; fell back to az cli bearer."
- detail = "Consider unsetting the stale variable."
+ self._emit_warning_with_detail(
+ f"ADO_APM_PAT was rejected for {host_display}; fell back to az cli bearer.",
+ "Consider unsetting the stale variable.",
+ fallback_context="stale-PAT",
+ )
+
+ def _emit_warning_with_detail(
+ self,
+ msg: str,
+ detail: str,
+ *,
+ fallback_context: str,
+ always_show_detail: bool = False,
+ ) -> None:
+ """Emit a msg+detail warning, preferring the wired DiagnosticCollector.
+
+ Shared by every deferred one-time ``[!]`` warning on this resolver
+ (stale-PAT fallback, github-auth-first opt-out, ...). Falls back to
+ inline ``_rich_warning`` when no ``InstallLogger`` is wired (e.g.
+ unit tests). Callers own their own dedup gate (e.g. a per-host set
+ or a boolean flag) before calling this.
+
+ ``DiagnosticCollector``'s warning renderer only prints ``detail``
+ under ``--verbose`` (by design for supplementary context like the
+ stale-PAT cleanup hint). When ``detail`` carries the *only*
+ remediation for the warning (the "So What test"), pass
+ ``always_show_detail=True`` to fold it into the always-rendered
+ message instead -- the ``_rich_warning`` fallback already shows
+ both unconditionally.
+ """
diagnostics = self._diagnostics_or_none()
if diagnostics is not None:
- diagnostics.warn(msg, detail=detail)
+ if always_show_detail:
+ diagnostics.warn(f"{msg} {detail}")
+ else:
+ diagnostics.warn(msg, detail=detail)
return
try:
from apm_cli.utils.console import _rich_warning
@@ -1557,7 +1721,11 @@ def emit_stale_pat_diagnostic(self, host_display: str) -> None:
_rich_warning(msg, symbol="warning")
_rich_warning(f" {detail}", symbol="warning")
except ImportError as exc:
- logger.debug("Console module unavailable for stale-PAT warning; skipping: %s", exc)
+ logger.debug(
+ "Console module unavailable for %s warning; skipping: %s",
+ fallback_context,
+ exc,
+ )
# Backwards-compat alias for any in-tree caller still importing the
# private name. Safe to remove once all callers move to the public name.
diff --git a/src/apm_cli/install/mcp/conflicts.py b/src/apm_cli/install/mcp/conflicts.py
index c69686915c..b4050e1874 100644
--- a/src/apm_cli/install/mcp/conflicts.py
+++ b/src/apm_cli/install/mcp/conflicts.py
@@ -38,11 +38,15 @@ def validate_mcp_conflicts(
update: bool,
any_transport_flag: bool,
registry_url: str | None = None,
+ auth_first: bool = False,
) -> None:
"""Apply the conflict matrix. Raises ``click.UsageError`` on a conflict.
``any_transport_flag`` should be ``use_ssh or use_https or
- allow_protocol_fallback`` (pre-evaluated by the caller).
+ allow_protocol_fallback`` (pre-evaluated by the caller). ``auth_first``
+ (issue #2545) is reported separately from ``any_transport_flag``: it
+ changes credential *order*, not the transport scheme, so it is not a
+ "transport selection flag".
"""
# E10: flags require --mcp -- run first so users get the right hint.
if mcp_name is None:
@@ -84,6 +88,14 @@ def validate_mcp_conflicts(
"don't apply to MCP entries"
)
+ # E4b: --auth-first (issue #2545) changes credential order, not
+ # transport, but still has nothing to configure on an MCP entry.
+ if auth_first:
+ raise click.UsageError(
+ "--auth-first (a github.com credential-order flag, not a transport "
+ "selector) doesn't apply to MCP entries"
+ )
+
# E5: --update is for refreshing, not adding.
if update:
raise click.UsageError("use 'apm update' instead to update MCP entries")
diff --git a/src/apm_cli/utils/git_env.py b/src/apm_cli/utils/git_env.py
index f3a24b102d..3a68e9f2e5 100644
--- a/src/apm_cli/utils/git_env.py
+++ b/src/apm_cli/utils/git_env.py
@@ -24,6 +24,7 @@
import re
import shutil
import subprocess
+import threading
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -738,6 +739,90 @@ def configured_git_url_policy(
return snapshot.rewrites, snapshot.http_headers
+# issue #2545: the only two keys inherited from the ambient (non-isolated)
+# git config into APM's anonymous-first github.com attempt. Deliberately
+# narrow -- credential.helper and other auth-bearing config are never read
+# here, so the credential-leakage boundary the isolated environment exists
+# for is unaffected.
+_TLS_TRANSPORT_CONFIG_KEYS: frozenset[str] = frozenset({"http.sslbackend", "http.sslcainfo"})
+
+# Module-level cache for the ambient (env=None) TLS probe -- see
+# real_git_tls_config(). None means "not probed yet"; {} is a legitimate,
+# cached "no ambient TLS config" answer, not a retry signal. Guarded by
+# _real_git_tls_config_lock: parallel installs call this once per
+# dependency, and the probe spawns a `git config` child with its own
+# 10s/30s retry budget, so an unguarded check-then-probe race could let
+# several workers pay that cost concurrently before either stores a result.
+_real_git_tls_config_cache: dict[str, str] | None = None
+_real_git_tls_config_lock = threading.Lock()
+
+
+def real_git_tls_config(env: dict[str, object] | None = None) -> dict[str, str]:
+ """Return the ambient ``http.sslBackend``/``http.sslCAInfo`` git config.
+
+ Reads the *non-isolated* config a plain ``git clone`` would see -- the
+ settings that let it succeed behind a TLS-inspecting proxy whose
+ intercepting CA chain carries no revocation info, while APM's
+ anonymous-first isolated environment (``GIT_CONFIG_NOSYSTEM=1`` + empty
+ ``GIT_CONFIG_GLOBAL``) cannot complete the same handshake. Keys are
+ returned lower-cased, matching Git's own ``--list`` normalization and
+ this codebase's existing indexed-config convention (see
+ ``AuthResolver._append_git_config`` callers using ``credential.helper``
+ / ``http.extraheader``).
+
+ Best-effort: any probe failure (missing ``git``, malformed output,
+ timeout) yields an empty mapping rather than raising, so a broken
+ ambient config never blocks the anonymous attempt it is meant to help.
+
+ ``entries`` is ordered lowest-to-highest precedence (system, global,
+ local, worktree, command/env), matching Git's own resolution. An
+ explicit higher-precedence empty value (``git config --unset``-like
+ override) must win over an earlier non-empty one, so every value for a
+ tracked key is recorded during the scan and only the final, possibly
+ empty, values are filtered out afterward.
+
+ Cached for the default ``env=None`` case after the first probe: the
+ ambient config a plain ``git clone`` sees is stable for the life of the
+ process, and both ``AuthResolver.build_public_github_anonymous_git_env``
+ and ``AuthResolver.git_env_for_context`` call this once per attempt, so
+ an uncached probe would spawn one extra ``git config`` child process
+ (with its own retry/timeout budget) per dependency in a large install.
+ Callers that pass an explicit ``env`` (tests) always re-probe. Use
+ :func:`reset_git_cache` to clear it (tests only).
+
+ Parallel installs call this from multiple worker threads: the cache is
+ populated under ``_real_git_tls_config_lock`` with a double-checked read
+ (fast, lock-free hit once warm; re-checked inside the lock before
+ probing) so concurrent workers racing the very first call share one
+ probe instead of each paying its own retry/timeout budget.
+ """
+ global _real_git_tls_config_cache
+ if env is None and _real_git_tls_config_cache is not None:
+ return dict(_real_git_tls_config_cache)
+
+ def _probe() -> dict[str, str]:
+ try:
+ snapshot = _read_effective_git_config(git_subprocess_env(env))
+ values: dict[str, str] = {}
+ for entry in snapshot.entries:
+ normalized = entry.key.lower()
+ if normalized in _TLS_TRANSPORT_CONFIG_KEYS:
+ values[normalized] = entry.value
+ return {key: value for key, value in values.items() if value}
+ except (GitUrlRewriteProbeError, OSError):
+ return {}
+
+ if env is not None:
+ return _probe()
+
+ with _real_git_tls_config_lock:
+ if _real_git_tls_config_cache is not None:
+ return dict(_real_git_tls_config_cache)
+ result = _probe()
+ _real_git_tls_config_cache = result
+ return dict(result)
+
+
def validate_resolved_git_url_rewrite(
remote_url: str,
effective_url: str,
@@ -1506,10 +1591,11 @@ def git_current_branch(
def reset_git_cache() -> None:
- """Reset the cached git executable (for testing purposes only)."""
- global _gh_executable, _git_executable
+ """Reset the cached git executable and TLS config probe (tests only)."""
+ global _gh_executable, _git_executable, _real_git_tls_config_cache
_git_executable = None
_gh_executable = None
+ _real_git_tls_config_cache = None
def git_long_paths_args() -> list[str]:
diff --git a/tests/integration/test_commands_config_coverage.py b/tests/integration/test_commands_config_coverage.py
index 082a6aedd0..5f8c4c94b7 100644
--- a/tests/integration/test_commands_config_coverage.py
+++ b/tests/integration/test_commands_config_coverage.py
@@ -246,6 +246,17 @@ def test_parse_allow_protocol_fallback_env(self) -> None:
for unknown in (None, "", "maybe"):
assert _conf._parse_allow_protocol_fallback_env(unknown) is None
+ def test_parse_github_auth_first_env(self) -> None:
+ """_parse_github_auth_first_env() handles all recognised values (issue #2545)."""
+ import apm_cli.config as _conf
+
+ for truthy in ("1", "true", "yes", "on", "TRUE", "YES"):
+ assert _conf._parse_github_auth_first_env(truthy) is True
+ for falsy in ("0", "false", "no", "off"):
+ assert _conf._parse_github_auth_first_env(falsy) is False
+ for unknown in (None, "", "maybe"):
+ assert _conf._parse_github_auth_first_env(unknown) is None
+
# ---------------------------------------------------------------------------
# apm config set
@@ -472,6 +483,11 @@ def test_unset_prefer_ssh(self, runner: CliRunner, isolated_config: Path) -> Non
result = runner.invoke(cli, ["config", "unset", "prefer-ssh"])
assert result.exit_code == 0
+ def test_unset_github_auth_first(self, runner: CliRunner, isolated_config: Path) -> None:
+ """apm config unset github-auth-first succeeds."""
+ result = runner.invoke(cli, ["config", "unset", "github-auth-first"])
+ assert result.exit_code == 0
+
def test_unset_mcp_registry_url(self, runner: CliRunner, isolated_config: Path) -> None:
"""apm config unset mcp-registry-url succeeds."""
result = runner.invoke(cli, ["config", "unset", "mcp-registry-url"])
diff --git a/tests/unit/core/test_github_auth_first_optout.py b/tests/unit/core/test_github_auth_first_optout.py
new file mode 100644
index 0000000000..7734a9ac87
--- /dev/null
+++ b/tests/unit/core/test_github_auth_first_optout.py
@@ -0,0 +1,483 @@
+"""Tests for the github-auth-first opt-out and TLS-config inheritance (issue #2545).
+
+Covers:
+- ``AuthResolver.uses_public_github_anonymous_first`` CLI flag > env var >
+ persisted config > default (False) resolution for exact-host github.com.
+- The non-github.com short-circuit (always False regardless of the opt-out).
+- ``AuthResolver.emit_github_auth_first_diagnostic`` one-time warning, both
+ via a wired ``DiagnosticCollector`` and the ``_rich_warning`` fallback.
+- ``AuthResolver.build_public_github_anonymous_git_env`` inheriting
+ ``http.sslBackend``/``http.sslCAInfo`` from :func:`real_git_tls_config`.
+"""
+
+from __future__ import annotations
+
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from apm_cli.core.auth import AuthContext, AuthResolver, HostInfo
+
+# ---------------------------------------------------------------------------
+# uses_public_github_anonymous_first -- resolution order
+# ---------------------------------------------------------------------------
+
+
+class TestUsesPublicGithubAnonymousFirstOptOut:
+ def test_default_is_anonymous_first_for_github_com(self) -> None:
+ """No CLI flag, env var, or config -> unchanged default (True)."""
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=False):
+ resolver = AuthResolver()
+ assert resolver.uses_public_github_anonymous_first("github.com") is True
+
+ def test_cli_flag_true_skips_anonymous_attempt(self) -> None:
+ """AuthResolver(github_auth_first=True) opts out regardless of env/config."""
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=False):
+ resolver = AuthResolver(github_auth_first=True)
+ assert resolver.uses_public_github_anonymous_first("github.com") is False
+
+ def test_env_var_true_skips_anonymous_attempt(self) -> None:
+ """APM_GITHUB_AUTH_FIRST=1 opts out without a CLI flag."""
+ with patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "1"}, clear=True):
+ resolver = AuthResolver()
+ assert resolver.uses_public_github_anonymous_first("github.com") is False
+
+ @pytest.mark.parametrize("env_val", ["0", "false", "no", "off"])
+ def test_env_var_explicit_falsy_overrides_persisted_config_true(self, env_val: str) -> None:
+ """Explicit falsy env value wins over a persisted config of True."""
+ with patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": env_val}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=True):
+ resolver = AuthResolver()
+ assert resolver.uses_public_github_anonymous_first("github.com") is True
+
+ def test_persisted_config_true_used_when_cli_and_env_absent(self) -> None:
+ """github_auth_first=True in ~/.apm/config.json opts out when unset elsewhere."""
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=True):
+ resolver = AuthResolver()
+ assert resolver.uses_public_github_anonymous_first("github.com") is False
+
+ def test_non_github_host_always_false_regardless_of_opt_out(self) -> None:
+ """GHE/ADO/GitLab/generic hosts never use the anonymous-first attempt."""
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=False):
+ resolver = AuthResolver(github_auth_first=True)
+ assert resolver.uses_public_github_anonymous_first("dev.azure.com") is False
+ assert resolver.uses_public_github_anonymous_first("gitlab.com") is False
+
+
+# ---------------------------------------------------------------------------
+# emit_github_auth_first_diagnostic -- one-time warning
+# ---------------------------------------------------------------------------
+
+
+class TestEmitGithubAuthFirstDiagnostic:
+ def test_emits_once_via_rich_warning_fallback(self) -> None:
+ resolver = AuthResolver()
+ with patch("apm_cli.utils.console._rich_warning") as mock_warn:
+ resolver.emit_github_auth_first_diagnostic()
+ resolver.emit_github_auth_first_diagnostic()
+ # One emission -> _rich_warning called twice (msg + recovery detail);
+ # the dedup'd second call adds nothing (mirrors TestStalePATDiagnosticDedup).
+ assert mock_warn.call_count == 2
+ assert "github-auth-first" in mock_warn.call_args_list[0].args[0]
+ assert "apm config unset github-auth-first" in mock_warn.call_args_list[1].args[0]
+
+ def test_emits_via_diagnostics_when_logger_wired(self) -> None:
+ resolver = AuthResolver()
+ diag = MagicMock()
+ logger = MagicMock()
+ logger.diagnostics = diag
+ resolver.set_logger(logger)
+
+ resolver.emit_github_auth_first_diagnostic()
+
+ diag.warn.assert_called_once()
+ # always_show_detail=True: the recovery instructions are folded into
+ # the always-rendered message, not a `detail=` kwarg the
+ # DiagnosticCollector would otherwise hide without --verbose.
+ assert "github-auth-first" in diag.warn.call_args.args[0]
+ assert "apm config unset github-auth-first" in diag.warn.call_args.args[0]
+ assert not diag.warn.call_args.kwargs.get("detail")
+
+ def test_triggered_once_through_uses_public_github_anonymous_first(self) -> None:
+ """Calling the decision method N times for the opt-out warns once."""
+ with patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "1"}, clear=True):
+ resolver = AuthResolver()
+ with patch("apm_cli.utils.console._rich_warning") as mock_warn:
+ for _ in range(3):
+ resolver.uses_public_github_anonymous_first("github.com")
+ assert mock_warn.call_count == 2
+
+ def test_no_warning_when_opt_out_inactive(self) -> None:
+ with patch.dict(os.environ, {}, clear=True):
+ with patch("apm_cli.config.get_github_auth_first", return_value=False):
+ resolver = AuthResolver()
+ with patch("apm_cli.utils.console._rich_warning") as mock_warn:
+ resolver.uses_public_github_anonymous_first("github.com")
+ mock_warn.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# build_public_github_anonymous_git_env -- ambient TLS config inheritance
+# ---------------------------------------------------------------------------
+
+
+class TestBuildAnonymousGitEnvTlsInheritance:
+ def test_inherits_ssl_backend_and_ca_info(self) -> None:
+ with patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel", "http.sslcainfo": "/ca/corp.pem"},
+ ):
+ env = AuthResolver.build_public_github_anonymous_git_env()
+
+ count = int(env["GIT_CONFIG_COUNT"])
+ config = {
+ env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"] for i in range(count)
+ }
+ assert config["http.sslbackend"] == "schannel"
+ assert config["http.sslcainfo"] == "/ca/corp.pem"
+ # credential.helper stays forced empty regardless of TLS inheritance.
+ assert config["credential.helper"] == ""
+
+ def test_no_ambient_tls_config_leaves_env_unchanged(self) -> None:
+ with patch("apm_cli.utils.git_env.real_git_tls_config", return_value={}):
+ env = AuthResolver.build_public_github_anonymous_git_env()
+
+ count = int(env["GIT_CONFIG_COUNT"])
+ keys = {env[f"GIT_CONFIG_KEY_{i}"] for i in range(count)}
+ assert "http.sslbackend" not in keys
+ assert "http.sslcainfo" not in keys
+
+ def test_tls_probe_failure_does_not_block_anonymous_env(self) -> None:
+ """A best-effort probe failure yields {} upstream; env building still succeeds."""
+ with patch("apm_cli.utils.git_env.real_git_tls_config", return_value={}):
+ env = AuthResolver.build_public_github_anonymous_git_env()
+ assert env["GIT_CONFIG_NOSYSTEM"] == "1"
+
+ def test_ambient_value_does_not_override_callers_explicit_config(self) -> None:
+ """Issue #2545 follow-up: GIT_CONFIG_* is last-one-wins in Git, so
+ blindly appending the ambient value after a caller's explicit
+ http.sslBackend/http.sslCAInfo would silently override their
+ choice. Ambient inheritance must only fill a gap."""
+ base_env = {
+ "GIT_CONFIG_COUNT": "1",
+ "GIT_CONFIG_KEY_0": "http.sslCAInfo",
+ "GIT_CONFIG_VALUE_0": "/explicit/caller-ca.pem",
+ }
+ with patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel", "http.sslcainfo": "/ambient-ca.pem"},
+ ):
+ env = AuthResolver.build_public_github_anonymous_git_env(base_env=base_env)
+
+ config = _indexed_git_config(env)
+ # The caller's explicit http.sslCAInfo survives untouched...
+ assert config["http.sslCAInfo"] == "/explicit/caller-ca.pem"
+ # ...http.sslcainfo (case-insensitive duplicate) is not also added...
+ assert list(config.values()).count("/ambient-ca.pem") == 0
+ # ...while the key the caller never set is still filled from ambient.
+ assert config["http.sslbackend"] == "schannel"
+
+
+# ---------------------------------------------------------------------------
+# try_with_fallback -- credentialed github.com attempts also inherit TLS
+# config (issue #2545 follow-up: --auth-first alone did not fix the
+# TLS-inspecting-proxy failure for users who already have a token, because
+# the credentialed retry built its env from the isolated hardened base).
+# ---------------------------------------------------------------------------
+
+
+def _indexed_git_config(env: dict[str, str]) -> dict[str, str]:
+ count = int(env.get("GIT_CONFIG_COUNT", "0"))
+ return {env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"] for i in range(count)}
+
+
+class _HttpStatusError(RuntimeError):
+ def __init__(self, status_code: int) -> None:
+ self.status_code = status_code
+ super().__init__(f"HTTP {status_code}")
+
+
+class TestCredentialedGithubEnvTlsInheritance:
+ def test_secondary_credential_fallback_inherits_tls_config(self) -> None:
+ """issue #2545 follow-up: the secondary chain (gh CLI, then git
+ credential fill) tried after the primary token also fails must not
+ lose the ambient TLS config either -- it also builds a github.com
+ env from an isolated base_env."""
+ resolver = AuthResolver()
+ base_env = {"GIT_CONFIG_GLOBAL": "/isolated/empty-gitconfig", "GIT_CONFIG_NOSYSTEM": "1"}
+ attempts: list[tuple[str | None, dict[str, str]]] = []
+
+ def operation(token: str | None, env: dict[str, str]) -> str:
+ attempts.append((token, env))
+ if token != "gh-cli-token":
+ raise _HttpStatusError(401)
+ return "ok"
+
+ with (
+ patch.dict(os.environ, {"GITHUB_APM_PAT_ACME": "primary-token"}, clear=True),
+ patch("apm_cli.config.get_github_auth_first", return_value=False),
+ patch.object(
+ resolver._token_manager,
+ "resolve_credential_from_gh_cli",
+ return_value="gh-cli-token",
+ ),
+ patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel"},
+ ),
+ ):
+ result = resolver.try_with_fallback(
+ "github.com",
+ operation,
+ org="acme",
+ path="acme/widgets",
+ unauth_first=True,
+ base_env=base_env,
+ )
+
+ assert result == "ok"
+ assert [token for token, _env in attempts] == [None, "primary-token", "gh-cli-token"]
+ secondary_env = attempts[-1][1]
+ assert _indexed_git_config(secondary_env)["http.sslbackend"] == "schannel"
+
+ def test_authenticated_retry_inherits_tls_config(self) -> None:
+ """The 401/403/404-triggered credentialed retry inherits ambient TLS
+ config even though base_env is an isolated hardened environment."""
+ resolver = AuthResolver()
+ base_env = {"GIT_CONFIG_GLOBAL": "/isolated/empty-gitconfig", "GIT_CONFIG_NOSYSTEM": "1"}
+ attempts: list[tuple[str | None, dict[str, str]]] = []
+
+ def operation(token: str | None, env: dict[str, str]) -> str:
+ attempts.append((token, env))
+ if token is None:
+ raise _HttpStatusError(404)
+ return "private-ok"
+
+ with (
+ patch.dict(os.environ, {"GITHUB_APM_PAT_ACME": "private-token"}, clear=True),
+ patch("apm_cli.config.get_github_auth_first", return_value=False),
+ patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel"},
+ ),
+ ):
+ result = resolver.try_with_fallback(
+ "github.com",
+ operation,
+ org="acme",
+ path="acme/widgets",
+ unauth_first=True,
+ base_env=base_env,
+ )
+
+ assert result == "private-ok"
+ assert [token for token, _env in attempts] == [None, "private-token"]
+ credentialed_env = attempts[1][1]
+ assert _indexed_git_config(credentialed_env)["http.sslbackend"] == "schannel"
+
+ def test_auth_first_bypass_clone_path_inherits_tls_config(self) -> None:
+ """github_downloader.py's --auth-first bypass calls git_env_for_remote()
+ directly (not try_with_fallback) once uses_public_github_anonymous_first()
+ is False; that direct call must still inherit the ambient TLS config."""
+ with patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "1"}, clear=True):
+ resolver = AuthResolver()
+ assert resolver.uses_public_github_anonymous_first("github.com") is False
+
+ ctx = AuthContext(
+ token="private-token",
+ source="test",
+ token_type="unknown",
+ host_info=HostInfo(
+ host="github.com",
+ kind="github",
+ has_public_repos=True,
+ api_base="https://api.github.com",
+ ),
+ git_env={},
+ )
+ with patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslcainfo": "/ca/corp.pem"},
+ ):
+ env = resolver.git_env_for_remote(
+ ctx,
+ "https://github.com/acme/widgets.git",
+ base_env={"GIT_CONFIG_GLOBAL": "/isolated/empty-gitconfig"},
+ )
+
+ assert _indexed_git_config(env)["http.sslcainfo"] == "/ca/corp.pem"
+
+ def test_non_github_host_unaffected(self) -> None:
+ """ADO/GitLab/generic credentialed envs are never probed for TLS config."""
+ resolver = AuthResolver()
+ with (
+ patch.dict(os.environ, {"ADO_APM_PAT": "pat-token"}, clear=True),
+ patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel"},
+ ) as mock_tls,
+ ):
+
+ def operation(token: str | None, env: dict[str, str]) -> tuple:
+ return (token, env)
+
+ # base_env set so the credentialed path actually routes through
+ # git_env_for_context() (base_env=None short-circuits to
+ # ctx.git_env directly, which would make this test a no-op).
+ token, env = resolver.try_with_fallback(
+ "dev.azure.com", operation, base_env={"PATH": "/usr/bin"}
+ )
+
+ assert token == "pat-token"
+ assert "http.sslbackend" not in _indexed_git_config(env)
+ mock_tls.assert_not_called()
+
+ def test_opted_out_caller_requesting_unauth_first_skips_anonymous_entirely(self) -> None:
+ """issue #2545 follow-up: a caller like validation.py always passes
+ unauth_first=True for github.com without checking the opt-out itself.
+ Before this fix, try_with_fallback() still tried operation(None, ...)
+ first regardless of the opt-out, defeating --auth-first for exactly
+ the callers that motivated it."""
+ with patch.dict(
+ os.environ,
+ {"APM_GITHUB_AUTH_FIRST": "1", "GITHUB_APM_PAT_ACME": "private-token"},
+ clear=True,
+ ):
+ resolver = AuthResolver()
+ attempts: list[str | None] = []
+
+ def operation(token: str | None, _env: dict[str, str]) -> str:
+ attempts.append(token)
+ return "ok"
+
+ result = resolver.try_with_fallback(
+ "github.com",
+ operation,
+ org="acme",
+ path="acme/widgets",
+ unauth_first=True,
+ )
+
+ assert result == "ok"
+ # The bug: attempts == [None, "private-token"] (anonymous tried first).
+ assert attempts == ["private-token"]
+
+ def test_opted_out_with_no_token_still_falls_back_to_anonymous(self) -> None:
+ """With the opt-out active but no credential resolvable at all, there
+ is nothing to jump straight to -- the last-resort anonymous attempt
+ must still happen instead of raising with no attempt at all."""
+ resolver = AuthResolver()
+ attempts: list[str | None] = []
+
+ def operation(token: str | None, _env: dict[str, str]) -> str:
+ attempts.append(token)
+ return "ok"
+
+ with (
+ patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "1"}, clear=True),
+ # Never let this test shell out to a real credential helper.
+ patch.object(
+ resolver._token_manager,
+ "resolve_credential_from_gh_cli",
+ return_value=None,
+ ),
+ patch.object(
+ resolver._token_manager,
+ "resolve_credential_from_git",
+ return_value=None,
+ ),
+ ):
+ result = resolver.try_with_fallback(
+ "github.com",
+ operation,
+ org="acme",
+ unauth_first=True,
+ )
+
+ assert result == "ok"
+ assert attempts == [None]
+
+ def test_non_github_caller_unauth_first_behavior_unchanged(self) -> None:
+ """The opt-out is exact-host github.com only; a GitLab/generic caller
+ that requests unauth_first=True still tries anonymous first."""
+ resolver = AuthResolver()
+ attempts: list[str | None] = []
+
+ def operation(token: str | None, _env: dict[str, str]) -> str:
+ attempts.append(token)
+ if token is None:
+ raise _HttpStatusError(401)
+ return "ok"
+
+ with patch.dict(os.environ, {"GITLAB_APM_PAT": "gl-token"}, clear=True):
+ result = resolver.try_with_fallback(
+ "gitlab.com",
+ operation,
+ org="acme",
+ unauth_first=True,
+ )
+
+ assert result == "ok"
+ assert attempts == [None, "gl-token"]
+
+ def test_git_env_for_context_does_not_override_callers_explicit_config(self) -> None:
+ """Same precedence protection as the anonymous env, for the
+ credentialed path (git_env_for_context)."""
+ ctx = AuthContext(
+ token="private-token",
+ source="test",
+ token_type="unknown",
+ host_info=HostInfo(
+ host="github.com",
+ kind="github",
+ has_public_repos=True,
+ api_base="https://api.github.com",
+ ),
+ git_env={},
+ )
+ base_env = {
+ "GIT_CONFIG_COUNT": "1",
+ "GIT_CONFIG_KEY_0": "http.sslBackend",
+ "GIT_CONFIG_VALUE_0": "openssl",
+ }
+ with patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel"},
+ ):
+ env = AuthResolver.git_env_for_context(ctx, base_env=base_env)
+
+ config = _indexed_git_config(env)
+ assert config["http.sslBackend"] == "openssl"
+ assert "schannel" not in config.values()
+
+ def test_git_env_for_context_returns_fresh_dict(self) -> None:
+ """git_env_for_context() never mutates its base_env or aliases a
+ cached AuthContext.git_env -- _build_git_env always copies first."""
+ ctx = AuthContext(
+ token="private-token",
+ source="test",
+ token_type="unknown",
+ host_info=HostInfo(
+ host="github.com",
+ kind="github",
+ has_public_repos=True,
+ api_base="https://api.github.com",
+ ),
+ git_env={},
+ )
+ base_env = {"MARK": "1"}
+ with patch(
+ "apm_cli.utils.git_env.real_git_tls_config",
+ return_value={"http.sslbackend": "schannel"},
+ ):
+ env1 = AuthResolver.git_env_for_context(ctx, base_env=base_env)
+ env2 = AuthResolver.git_env_for_context(ctx, base_env=base_env)
+ assert base_env == {"MARK": "1"}, "base_env must not be mutated in place"
+ assert env1 is not env2
+ assert list(_indexed_git_config(env1).values()).count("schannel") == 1
+ assert list(_indexed_git_config(env2).values()).count("schannel") == 1
diff --git a/tests/unit/deps/test_github_downloader_input_validation.py b/tests/unit/deps/test_github_downloader_input_validation.py
index 71f08ec826..a6818b5033 100644
--- a/tests/unit/deps/test_github_downloader_input_validation.py
+++ b/tests/unit/deps/test_github_downloader_input_validation.py
@@ -479,7 +479,12 @@ def test_token_attempt_preserves_retained_git_config(self) -> None:
AuthResolver.git_env_for_context(ctx, base_env=dl.git_env)
)
- attempts = _build_validation_attempts(dl, dep, lambda m: None)
+ # issue #2545: git_env_for_context() also probes the ambient TLS
+ # config for github.com contexts; pin it to {} so this test's exact
+ # GIT_CONFIG_COUNT assertion is independent of the host's real git
+ # config (some machines set http.sslBackend globally).
+ with patch("apm_cli.utils.git_env.real_git_tls_config", return_value={}):
+ attempts = _build_validation_attempts(dl, dep, lambda m: None)
token_env = attempts[0].env
assert token_env["GIT_CONFIG_COUNT"] == "3"
assert token_env["GIT_CONFIG_KEY_0"] == "safe.bareRepository"
diff --git a/tests/unit/install/test_install_local_bundle.py b/tests/unit/install/test_install_local_bundle.py
index 4ebf349aa9..b9abf9bc23 100644
--- a/tests/unit/install/test_install_local_bundle.py
+++ b/tests/unit/install/test_install_local_bundle.py
@@ -344,6 +344,7 @@ def test_dependency_ref_can_be_none(self) -> None:
("--ssh", None),
("--https", None),
("--allow-protocol-fallback", None),
+ ("--auth-first", None),
("--mcp", "io.github.test/test"),
("--registry", "https://example.com"),
("--skill", "my-skill"),
diff --git a/tests/unit/install/test_mcp_conflicts.py b/tests/unit/install/test_mcp_conflicts.py
index c2ab49931a..e63a13916e 100644
--- a/tests/unit/install/test_mcp_conflicts.py
+++ b/tests/unit/install/test_mcp_conflicts.py
@@ -198,6 +198,27 @@ def test_none_set_ok(self) -> None:
_call(any_transport_flag=False)
+# ---------------------------------------------------------------------------
+# E4b - --auth-first doesn't apply to MCP (issue #2545)
+# ---------------------------------------------------------------------------
+
+
+class TestE4bAuthFirst:
+ def test_auth_first_raises(self) -> None:
+ """--auth-first is reported separately: it's not a transport flag."""
+ with pytest.raises(click.UsageError, match=r"--auth-first"):
+ _call(auth_first=True)
+
+ def test_auth_first_message_does_not_claim_transport_selection(self) -> None:
+ """The --auth-first error must not say 'transport selection flags'."""
+ with pytest.raises(click.UsageError) as exc_info:
+ _call(auth_first=True)
+ assert "transport selection flags" not in str(exc_info.value)
+
+ def test_auth_first_false_ok(self) -> None:
+ _call(auth_first=False)
+
+
# ---------------------------------------------------------------------------
# E5 - --update is for refreshing
# ---------------------------------------------------------------------------
diff --git a/tests/unit/test_config_command.py b/tests/unit/test_config_command.py
index d58d03e736..06faa5e09f 100644
--- a/tests/unit/test_config_command.py
+++ b/tests/unit/test_config_command.py
@@ -1329,6 +1329,106 @@ def test_empty_env_var_falls_through_to_config(self):
assert cfg_module.get_apm_allow_protocol_fallback() is True
+class TestGithubAuthFirstFunctions:
+ """Tests for get_github_auth_first and set_github_auth_first in apm_cli.config."""
+
+ def test_get_github_auth_first_default_is_false(self):
+ """Default value is False when not set."""
+ import apm_cli.config as cfg_module
+
+ with patch.object(cfg_module, "get_config", return_value={}):
+ assert cfg_module.get_github_auth_first() is False
+
+ def test_get_github_auth_first_true(self):
+ """Returns True when set to True."""
+ import apm_cli.config as cfg_module
+
+ with patch.object(cfg_module, "get_config", return_value={"github_auth_first": True}):
+ assert cfg_module.get_github_auth_first() is True
+
+ def test_set_github_auth_first_calls_update_config(self):
+ """set_github_auth_first delegates to update_config."""
+ import apm_cli.config as cfg_module
+
+ with patch.object(cfg_module, "update_config") as mock_update:
+ cfg_module.set_github_auth_first(True)
+ mock_update.assert_called_once_with({"github_auth_first": True})
+
+ def test_set_github_auth_first_false_calls_update_config(self):
+ """set_github_auth_first(False) passes False to update_config."""
+ import apm_cli.config as cfg_module
+
+ with patch.object(cfg_module, "update_config") as mock_update:
+ cfg_module.set_github_auth_first(False)
+ mock_update.assert_called_once_with({"github_auth_first": False})
+
+
+class TestGetApmGithubAuthFirst:
+ """Tests for get_apm_github_auth_first resolution chain."""
+
+ def test_env_var_wins_over_config(self):
+ """APM_GITHUB_AUTH_FIRST=1 wins even when config is False."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=False),
+ patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "1"}),
+ ):
+ assert cfg_module.get_apm_github_auth_first() is True
+
+ def test_env_var_true_wins(self):
+ """APM_GITHUB_AUTH_FIRST=true is accepted."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=False),
+ patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "true"}),
+ ):
+ assert cfg_module.get_apm_github_auth_first() is True
+
+ def test_config_used_when_env_unset(self):
+ """Config value is used when APM_GITHUB_AUTH_FIRST is unset."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=True),
+ patch.dict(os.environ, {}, clear=False),
+ ):
+ os.environ.pop("APM_GITHUB_AUTH_FIRST", None)
+ assert cfg_module.get_apm_github_auth_first() is True
+
+ def test_returns_false_when_both_unset(self):
+ """Returns False when neither env var nor config is set."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=False),
+ patch.dict(os.environ, {}, clear=False),
+ ):
+ os.environ.pop("APM_GITHUB_AUTH_FIRST", None)
+ assert cfg_module.get_apm_github_auth_first() is False
+
+ def test_env_var_explicit_zero_overrides_config_true(self):
+ """APM_GITHUB_AUTH_FIRST=0 overrides a persisted config value of True."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=True),
+ patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": "0"}),
+ ):
+ assert cfg_module.get_apm_github_auth_first() is False
+
+ def test_empty_env_var_falls_through_to_config(self):
+ """Empty APM_GITHUB_AUTH_FIRST (unset/empty) falls back to config."""
+ import apm_cli.config as cfg_module
+
+ with (
+ patch.object(cfg_module, "get_github_auth_first", return_value=True),
+ patch.dict(os.environ, {"APM_GITHUB_AUTH_FIRST": ""}),
+ ):
+ assert cfg_module.get_apm_github_auth_first() is True
+
+
class TestGetApmProtocolPref:
"""Tests for get_apm_protocol_pref resolution chain."""
@@ -1501,6 +1601,103 @@ def test_unknown_key_error_lists_allow_protocol_fallback(self):
assert "allow-protocol-fallback" in result.output
+# ---------------------------------------------------------------------------
+# CLI -- apm config set github-auth-first
+# ---------------------------------------------------------------------------
+
+
+class TestConfigSetGithubAuthFirst:
+ """Tests for `apm config set github-auth-first `."""
+
+ def setup_method(self):
+ self.runner = CliRunner()
+
+ def test_set_github_auth_first_true(self):
+ """Set github-auth-first to true."""
+ with patch("apm_cli.config.set_github_auth_first") as mock_set:
+ result = self.runner.invoke(config, ["set", "github-auth-first", "true"])
+ assert result.exit_code == 0
+ mock_set.assert_called_once_with(True)
+
+ def test_set_github_auth_first_false(self):
+ """Set github-auth-first to false."""
+ with patch("apm_cli.config.set_github_auth_first") as mock_set:
+ result = self.runner.invoke(config, ["set", "github-auth-first", "false"])
+ assert result.exit_code == 0
+ mock_set.assert_called_once_with(False)
+
+ def test_set_github_auth_first_invalid_value(self):
+ """Reject an invalid value."""
+ result = self.runner.invoke(config, ["set", "github-auth-first", "maybe"])
+ assert result.exit_code == 1
+
+ def test_set_github_auth_first_not_gated(self):
+ """github-auth-first does not require any experimental flag."""
+ with patch("apm_cli.config.set_github_auth_first"):
+ result = self.runner.invoke(config, ["set", "github-auth-first", "true"])
+ assert result.exit_code == 0
+
+
+# ---------------------------------------------------------------------------
+# CLI -- apm config get github-auth-first
+# ---------------------------------------------------------------------------
+
+
+class TestConfigGetGithubAuthFirst:
+ """Tests for `apm config get github-auth-first`."""
+
+ def setup_method(self):
+ self.runner = CliRunner()
+
+ def test_get_github_auth_first_when_true(self):
+ """Display the configured github-auth-first when True."""
+ with patch("apm_cli.config.get_github_auth_first", return_value=True):
+ result = self.runner.invoke(config, ["get", "github-auth-first"])
+ assert result.exit_code == 0
+ assert "github-auth-first: true" in result.output
+
+ def test_get_github_auth_first_when_false(self):
+ """Display the configured github-auth-first when False."""
+ with patch("apm_cli.config.get_github_auth_first", return_value=False):
+ result = self.runner.invoke(config, ["get", "github-auth-first"])
+ assert result.exit_code == 0
+ assert "github-auth-first: false" in result.output
+
+ def test_get_all_config_includes_github_auth_first(self):
+ """apm config get (no key) shows github-auth-first only when true."""
+ with (
+ patch("apm_cli.config.get_auto_integrate", return_value=True),
+ patch("apm_cli.config.get_allow_protocol_fallback", return_value=False),
+ patch("apm_cli.config.get_prefer_ssh", return_value=False),
+ patch("apm_cli.config.get_github_auth_first", return_value=True),
+ patch("apm_cli.config.get_temp_dir", return_value=None),
+ patch("apm_cli.core.experimental.is_enabled", return_value=False),
+ ):
+ result = self.runner.invoke(config, ["get"])
+ assert result.exit_code == 0
+ assert "github-auth-first" in result.output
+
+ def test_get_all_config_suppresses_github_auth_first_when_false(self):
+ """apm config get (no key) omits github-auth-first when false (noise reduction)."""
+ with (
+ patch("apm_cli.config.get_auto_integrate", return_value=True),
+ patch("apm_cli.config.get_allow_protocol_fallback", return_value=False),
+ patch("apm_cli.config.get_prefer_ssh", return_value=False),
+ patch("apm_cli.config.get_github_auth_first", return_value=False),
+ patch("apm_cli.config.get_temp_dir", return_value=None),
+ patch("apm_cli.core.experimental.is_enabled", return_value=False),
+ ):
+ result = self.runner.invoke(config, ["get"])
+ assert result.exit_code == 0
+ assert "github-auth-first" not in result.output
+
+ def test_unknown_key_error_lists_github_auth_first(self):
+ """Error message for unknown keys lists github-auth-first as valid."""
+ result = self.runner.invoke(config, ["get", "nonexistent"])
+ assert result.exit_code == 1
+ assert "github-auth-first" in result.output
+
+
# ---------------------------------------------------------------------------
# CLI -- apm config set prefer-ssh
# ---------------------------------------------------------------------------
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 66d83477c3..aca93275d2 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -2593,6 +2593,48 @@ def test_e4_mcp_with_ssh(self):
assert result.exit_code == 2
assert "transport selection flags" in result.output
+ def test_e4b_mcp_with_auth_first(self):
+ """--auth-first (issue #2545) is rejected with --mcp, reported separately
+ from the transport-selection flags since it changes credential order,
+ not transport."""
+ with self._chdir_with_apm_yml():
+ result = self.runner.invoke(cli, ["install", "--mcp", "foo", "--auth-first"])
+ assert result.exit_code == 2
+ assert "--auth-first" in result.output
+ assert "transport selection flags" not in result.output
+
+ def test_auth_first_flag_threads_into_auth_resolver(self):
+ """--auth-first (issue #2545) constructs AuthResolver(github_auth_first=True).
+
+ Wraps (rather than replaces) the real class so the rest of the install
+ pipeline still operates on a genuine ``AuthResolver`` instance.
+ """
+ from apm_cli.core.auth import AuthResolver
+
+ with (
+ self._chdir_with_apm_yml(),
+ patch(
+ "apm_cli.commands.install.AuthResolver", side_effect=AuthResolver
+ ) as mock_resolver_cls,
+ ):
+ result = self.runner.invoke(cli, ["install", "--auth-first"])
+ assert result.exit_code == 0, result.output
+ mock_resolver_cls.assert_called_once_with(github_auth_first=True)
+
+ def test_auth_first_flag_defaults_to_false(self):
+ """Without --auth-first, the CLI-scoped override passed is False."""
+ from apm_cli.core.auth import AuthResolver
+
+ with (
+ self._chdir_with_apm_yml(),
+ patch(
+ "apm_cli.commands.install.AuthResolver", side_effect=AuthResolver
+ ) as mock_resolver_cls,
+ ):
+ result = self.runner.invoke(cli, ["install"])
+ assert result.exit_code == 0, result.output
+ mock_resolver_cls.assert_called_once_with(github_auth_first=False)
+
def test_e5_mcp_with_update(self):
with self._chdir_with_apm_yml():
result = self.runner.invoke(cli, ["install", "--mcp", "foo", "--update"])
diff --git a/tests/unit/utils/test_real_git_tls_config.py b/tests/unit/utils/test_real_git_tls_config.py
new file mode 100644
index 0000000000..d80caf3eaa
--- /dev/null
+++ b/tests/unit/utils/test_real_git_tls_config.py
@@ -0,0 +1,240 @@
+"""Tests for ``real_git_tls_config`` (issue #2545).
+
+Reads the ambient (non-isolated) ``http.sslBackend``/``http.sslCAInfo`` git
+config that lets a plain ``git clone`` succeed behind a TLS-inspecting proxy
+whose intercepting CA chain has no revocation info.
+"""
+
+from __future__ import annotations
+
+import threading
+from unittest.mock import patch
+
+from apm_cli.utils.git_env import (
+ GitConfigEntry,
+ GitUrlRewriteProbeError,
+ _GitConfigSnapshot,
+ real_git_tls_config,
+ reset_git_cache,
+)
+
+
+def _snapshot(*entries: tuple[str, str]) -> _GitConfigSnapshot:
+ return _GitConfigSnapshot(
+ tuple(GitConfigEntry("global", key, value) for key, value in entries),
+ (),
+ (),
+ )
+
+
+class TestRealGitTlsConfig:
+ # issue #2545 follow-up: real_git_tls_config() caches its env=None
+ # result at module scope (perf fix), so every test below that calls it
+ # with the default env=None must start from a clean cache -- otherwise
+ # whichever test runs first "wins" for the rest of the session.
+ def setup_method(self) -> None:
+ reset_git_cache()
+
+ def teardown_method(self) -> None:
+ reset_git_cache()
+
+ def test_returns_ssl_backend_and_ca_info(self) -> None:
+ snapshot = _snapshot(
+ ("http.sslbackend", "schannel"),
+ ("http.sslcainfo", "/etc/ssl/corp-ca.pem"),
+ )
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {
+ "http.sslbackend": "schannel",
+ "http.sslcainfo": "/etc/ssl/corp-ca.pem",
+ }
+
+ def test_ignores_unrelated_config_keys(self) -> None:
+ """credential.helper and insteadOf rewrites are never read here."""
+ snapshot = _snapshot(
+ ("credential.helper", "store"),
+ ("url.file:///fixture/.insteadof", "https://github.com/acme"),
+ ("http.sslbackend", "schannel"),
+ )
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {"http.sslbackend": "schannel"}
+
+ def test_empty_value_excluded(self) -> None:
+ snapshot = _snapshot(("http.sslbackend", ""))
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {}
+
+ def test_no_ambient_tls_config_returns_empty_mapping(self) -> None:
+ snapshot = _snapshot(("core.autocrlf", "true"))
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {}
+
+ def test_higher_precedence_empty_value_overrides_earlier_non_empty(self) -> None:
+ """A later (higher-precedence) empty value must win over an earlier
+ non-empty one -- ``entries`` is ordered lowest-to-highest precedence,
+ matching Git's own resolution (issue #2545 follow-up)."""
+ snapshot = _snapshot(
+ ("http.sslcainfo", "/global/corp-ca.pem"), # e.g. global scope
+ ("http.sslcainfo", ""), # e.g. local scope explicitly unsets it
+ )
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {}
+
+ def test_higher_precedence_non_empty_value_overrides_earlier_non_empty(self) -> None:
+ snapshot = _snapshot(
+ ("http.sslbackend", "openssl"),
+ ("http.sslbackend", "schannel"),
+ )
+ with patch("apm_cli.utils.git_env._read_effective_git_config", return_value=snapshot):
+ assert real_git_tls_config() == {"http.sslbackend": "schannel"}
+
+ def test_probe_error_returns_empty_mapping(self) -> None:
+ """A best-effort probe failure never blocks the anonymous attempt."""
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ side_effect=GitUrlRewriteProbeError("probe timed out"),
+ ):
+ assert real_git_tls_config() == {}
+
+ def test_oserror_returns_empty_mapping(self) -> None:
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ side_effect=OSError("git not found"),
+ ):
+ assert real_git_tls_config() == {}
+
+ def test_passes_caller_env_through_to_git_subprocess_env(self) -> None:
+ snapshot = _snapshot()
+ with (
+ patch(
+ "apm_cli.utils.git_env.git_subprocess_env",
+ return_value={"PATH": "/usr/bin"},
+ ) as mock_sanitize,
+ patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=snapshot,
+ ) as mock_read,
+ ):
+ real_git_tls_config({"PATH": "/usr/bin"})
+
+ mock_sanitize.assert_called_once_with({"PATH": "/usr/bin"})
+ mock_read.assert_called_once_with({"PATH": "/usr/bin"})
+
+
+class TestRealGitTlsConfigCaching:
+ """issue #2545 follow-up: avoid one ``git config`` child process per
+ dependency in a large install -- cache the env=None probe once."""
+
+ def setup_method(self) -> None:
+ reset_git_cache()
+
+ def teardown_method(self) -> None:
+ reset_git_cache()
+
+ def test_second_call_does_not_reprobe(self) -> None:
+ snapshot = _snapshot(("http.sslbackend", "schannel"))
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=snapshot,
+ ) as mock_read:
+ first = real_git_tls_config()
+ second = real_git_tls_config()
+
+ assert first == {"http.sslbackend": "schannel"}
+ assert second == {"http.sslbackend": "schannel"}
+ mock_read.assert_called_once()
+
+ def test_empty_result_is_cached_too(self) -> None:
+ """An empty ambient config is a real, stable answer -- not a
+ signal to keep retrying the probe on every subsequent call."""
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=_snapshot(),
+ ) as mock_read:
+ assert real_git_tls_config() == {}
+ assert real_git_tls_config() == {}
+
+ mock_read.assert_called_once()
+
+ def test_probe_failure_is_cached_too(self) -> None:
+ """A failed probe is also cached -- retrying the same slow/failing
+ subprocess on every attempt would be worse than a stale {}."""
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ side_effect=OSError("git not found"),
+ ) as mock_read:
+ assert real_git_tls_config() == {}
+ assert real_git_tls_config() == {}
+
+ mock_read.assert_called_once()
+
+ def test_explicit_env_always_reprobes(self) -> None:
+ """Callers that pass an explicit env (tests, sandboxed probes)
+ bypass the cache -- only the default env=None case is cached."""
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=_snapshot(("http.sslbackend", "schannel")),
+ ) as mock_read:
+ real_git_tls_config({"PATH": "/usr/bin"})
+ real_git_tls_config({"PATH": "/usr/bin"})
+
+ assert mock_read.call_count == 2
+
+ def test_returned_mapping_is_a_copy(self) -> None:
+ """Mutating a caller's returned dict must not corrupt the cache."""
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=_snapshot(("http.sslbackend", "schannel")),
+ ):
+ first = real_git_tls_config()
+ first["http.sslbackend"] = "corrupted"
+ second = real_git_tls_config()
+
+ assert second == {"http.sslbackend": "schannel"}
+
+ def test_reset_git_cache_forces_reprobe(self) -> None:
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ return_value=_snapshot(("http.sslbackend", "schannel")),
+ ) as mock_read:
+ real_git_tls_config()
+ reset_git_cache()
+ real_git_tls_config()
+
+ assert mock_read.call_count == 2
+
+ def test_concurrent_first_calls_share_one_probe(self) -> None:
+ """Parallel installs call this from multiple worker threads; the
+ very first (cold-cache) burst must still probe exactly once, not
+ once per racing thread (issue #2545 follow-up)."""
+ from concurrent.futures import ThreadPoolExecutor
+
+ probe_started = threading.Event()
+ release_probe = threading.Event()
+ call_count = 0
+ call_count_lock = threading.Lock()
+
+ def _slow_read(_env: dict[str, str]) -> _GitConfigSnapshot:
+ nonlocal call_count
+ with call_count_lock:
+ call_count += 1
+ probe_started.set()
+ release_probe.wait(timeout=5)
+ return _snapshot(("http.sslbackend", "schannel"))
+
+ with patch(
+ "apm_cli.utils.git_env._read_effective_git_config",
+ side_effect=_slow_read,
+ ):
+ with ThreadPoolExecutor(max_workers=8) as pool:
+ futures = [pool.submit(real_git_tls_config) for _ in range(8)]
+ assert probe_started.wait(timeout=5)
+ # Give any racing (unguarded) second probe a chance to start
+ # before releasing the first one -- this is what the lock
+ # must prevent.
+ threading.Event().wait(0.05)
+ release_probe.set()
+ results = [f.result(timeout=5) for f in futures]
+
+ assert call_count == 1
+ assert all(r == {"http.sslbackend": "schannel"} for r in results)