Uh oh!
There was an error while loading. Please reload this page.
feat(transport): TransportSelector + strict-by-default transport (#778) - #779
Conversation
Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…one env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Daniel Meppiel (danielmeppiel)
commented
Apr 20, 2026
Visual reference for reviewersThree diagrams to make the PR easier to read end-to-end. All flows live in 1. Selection: input -> |
There was a problem hiding this comment.
Pull request overview
Implements Transport Selection v1 so APM distinguishes explicit transport (scheme) from shorthand and becomes strict-by-default, with an opt-in legacy cross-protocol fallback via CLI/env.
Changes:
- Add
TransportSelectordecision engine and route clone attempts through a computedTransportPlan. - Add
apm installflags/env vars to control shorthand transport (--ssh/--https,APM_GIT_PROTOCOL) and legacy fallback (--allow-protocol-fallback,APM_ALLOW_PROTOCOL_FALLBACK). - Add unit + integration coverage and update docs/CHANGELOG for the new contract.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_transport_selection.py | New unit test matrix for transport planning and insteadOf resolution/caching. |
| tests/unit/test_auth_scoping.py | Regression tests for per-attempt env + token scoping under fallback. |
| tests/integration/test_transport_selection_integration.py | New networked integration coverage validating strictness + overrides. |
| tests/fixtures/gitconfig_insteadof_to_ssh | Fixture .gitconfig for insteadOf HTTPS->SSH rewrite. |
| src/apm_cli/models/dependency/reference.py | Add explicit_scheme signal to parsed dependency references. |
| src/apm_cli/install/service.py | Thread transport prefs into the install pipeline. |
| src/apm_cli/install/request.py | Extend request with protocol preference + fallback toggle. |
| src/apm_cli/install/pipeline.py | Thread protocol preference + fallback into InstallContext. |
| src/apm_cli/install/phases/resolve.py | Construct downloader with protocol pref + fallback. |
| src/apm_cli/install/context.py | Store protocol pref + fallback on context. |
| src/apm_cli/deps/transport_selection.py | New transport selection module (plan + insteadOf resolver). |
| src/apm_cli/deps/github_downloader.py | Consume TransportPlan; implement strict-by-default cloning + warnings. |
| src/apm_cli/commands/install.py | Add --ssh/--https/--allow-protocol-fallback and resolve env/flag behavior. |
| scripts/test-integration.sh | Add CI integration test invocation for transport selection suite. |
| packages/apm-guide/.apm/skills/apm-usage/dependencies.md | Document strict selection + overrides + fallback escape hatch. |
| docs/src/content/docs/reference/cli-commands.md | Document new flags and transport env vars for apm install. |
| docs/src/content/docs/guides/dependencies.md | Document strict transport contract and migration/fallback guidance. |
| docs/src/content/docs/getting-started/authentication.md | Update SSH hang guidance and link to transport selection docs. |
| CHANGELOG.md | Add BREAKING/Added entries describing transport selection changes. |
Copilot's findings
Comments suppressed due to low confidence (2)
CHANGELOG.md:18
- This entry is very long and doesn't match the changelog format used elsewhere (concise, one line per PR, ending with
(#PR)), and it mixes multiple concerns into one bullet. Please condense and ensure the bullet ends with the PR number (e.g.(#778)).
### Added
- `apm install` accepts `--ssh` / `--https` flags and `APM_GIT_PROTOCOL=ssh|https` env to pick the initial transport for shorthand URLs. `apm install` also accepts `--allow-protocol-fallback` (env: `APM_ALLOW_PROTOCOL_FALLBACK=1`) as the escape hatch for cross-protocol fallback when migrating off the previous permissive behavior (#778).
- Add APM Review Panel skill (`.github/skills/apm-review-panel/`) and four new specialist personas (`devx-ux-expert`, `supply-chain-security-expert`, `apm-ceo`, `oss-growth-hacker`) with auto-activating per-persona skills. Routes specialist findings through an APM CEO arbiter for strategic / breaking-change calls, with the OSS growth hacker side-channeling adoption insights via `WIP/growth-strategy.md`. Instrumentation per Handbook Ch. 9 (`The Instrumented Codebase`); PROSE-compliant (thin SKILL.md routers, persona detail lazy-loaded via markdown links, explicit boundaries per persona).
docs/src/content/docs/guides/dependencies.md:155
- This excerpt contains non-ASCII punctuation (e.g. em dash "—" and Unicode arrow "→"). The repo's cross-platform encoding rules require docs to stay printable ASCII; please replace with ASCII equivalents ("--", "->", straight quotes, etc.).
>
> ```yaml
> # DON'T — ambiguous: APM can't tell where the repo path ends
> # gitlab.com/group/subgroup/repo/file.prompt.md
> # → parsed as repo=group/subgroup, virtual=repo/file.prompt.md (wrong!)
- Files reviewed: 19/19 changed files
- Comments generated: 5
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Daniel Meppiel (danielmeppiel)
commented
Apr 20, 2026
Both findings addressed in 1. Non-ASCII punctuation in 2. CHANGELOG entry too long, mixed concerns -- valid. Split the original two-concern bullet into:
All three entries end with |
Daniel Meppiel (danielmeppiel)
commented
Apr 20, 2026
Architecture: design patterns at playA fourth diagram for reviewers who want the design rationale rather than the runtime flow. classDiagram
direction LR
class ProtocolPreference {
<<Enum>>
NONE
SSH
HTTPS
}
class TransportAttempt {
<<Value Object / frozen dataclass>>
+use_ssh: bool
+use_token: bool
+reason: str
}
class TransportPlan {
<<Value Object / frozen dataclass>>
+attempts: list~TransportAttempt~
+strict: bool
+fallback_hint: str
}
class InsteadOfResolver {
<<Protocol / Strategy>>
+resolve(host) Optional~str~
}
class GitConfigInsteadOfResolver {
<<Default Strategy>>
-_cache: dict
-_lock: Lock
+resolve(host) Optional~str~
-_load_rewrites()
}
class FakeInsteadOfResolver {
<<Test Double>>
+rewrites: dict
+resolve(host) Optional~str~
}
class TransportSelector {
<<Factory / Pure Function>>
-insteadof_resolver: InsteadOfResolver
+select(dep_ref, cli_pref, allow_fallback, has_token) TransportPlan
}
class GitHubPackageDownloader {
<<Orchestrator>>
-protocol_pref: ProtocolPreference
-allow_protocol_fallback: bool
-github_token: str
-git_env: dict
-selector: TransportSelector
+download(dep_ref) Path
-_clone_with_fallback(...)
-_env_for(use_token) dict
-_build_repo_url(..., token: str) str
}
class InstallContext {
<<DI Container>>
+protocol_pref: ProtocolPreference
+allow_protocol_fallback: bool
}
class CliInstallCmd {
<<Adapter>>
+--ssh / --https
+--allow-protocol-fallback
+APM_GIT_PROTOCOL env
+APM_ALLOW_PROTOCOL_FALLBACK env
}
InsteadOfResolver <|.. GitConfigInsteadOfResolver : implements
InsteadOfResolver <|.. FakeInsteadOfResolver : implements
TransportSelector o-- InsteadOfResolver : injected (Strategy)
TransportSelector ..> TransportPlan : returns
TransportPlan o-- TransportAttempt : composed of
TransportSelector ..> ProtocolPreference : consumes
GitHubPackageDownloader o-- TransportSelector : composed
GitHubPackageDownloader ..> TransportPlan : iterates
InstallContext ..> GitHubPackageDownloader : configures
CliInstallCmd ..> InstallContext : populates
Patterns mapped
What this design buys reviewers
|
Daniel Meppiel (danielmeppiel)
commented
Apr 20, 2026
Integration with neighboring systemsHow TransportSelector composes with the rest of the install pipeline. 1. Lockfile |
| Surface | Interaction with TransportSelector | Status after #778 |
|---|---|---|
LockedDependency.repo_url | Identity is transport-agnostic; selection runs fresh on every install | Unchanged |
LockedDependency.port | Read by _build_repo_url, re-emitted on every attempt | Unchanged (#661 fix preserved) |
apm-policy.yml | No git_transport policy field today | Gap; future opportunity for org-level enforcement of strict-only |
PROXY_REGISTRY_URL / Artifactory | Disjoint code path (HTTP file fetch, not git clone) | Unchanged; proxy users unaffected |
The net effect: the breaking change is localized to the direct-git-clone code path, which is exactly the surface that #661 / #328 were filed against. Lockfile, port handling, and proxy users see no behavior change.
| APM picks SSH or HTTPS per dependency using a strict, predictable contract. | ||
| :::caution[Breaking change] | ||
| Earlier versions silently retried failed clones across protocols. The current |
There was a problem hiding this comment.
Indicate here APM < 0.8.13
There was a problem hiding this comment.
Done in 51ab924. Tightened the caution panel to:
Breaking change in APM 0.8.13 -- APM versions before 0.8.13 silently retried failed clones across protocols. Starting in 0.8.13 the behavior is strict by default [...]
That makes the version cutover unambiguous for anyone landing on this page from a search result without scrolling to the changelog.
While here, also took care of the four other Copilot review findings in the same commit:
transport_selection.py-- collapsed theexplicit == "http"branch into plain HTTPS soTransportAttempt.schemestays in{ssh, https}and the downloader contract is consistent. Plainhttp://URLs now build an HTTPS attempt with no token (no cleartext credential leak possible) until feat: support allow-insecure HTTP dependencies #700 introduces a first-class HTTP transport with explicit--allow-insecuregating.github_downloader.py-- the[!]"Protocol fallback" warning is now gated on actual scheme change (ssh <-> https) rather than label change, so an auth-only downgrade inside a single protocol (e.g. authenticated HTTPS -> plain HTTPS in a--ssh + --allow-protocol-fallbackmixed plan) is no longer misreported as a protocol switch.pipeline.py/request.py/context.py/commands/install.py--allow_protocol_fallbackis nowOptional[bool] = Noneend-to-end. Non-CLI programmatic callers regain the documented "None => readAPM_ALLOW_PROTOCOL_FALLBACKenv" behavior. The CLI still resolves env at the command boundary so behavior there is unchanged.tests/unit/test_auth_scoping.py-- replaced the one stray Unicode arrow with->to satisfy the ASCII-only source rule.
All 4061 unit tests pass.
- docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13
(per maintainer review comment)
- transport_selection: collapse explicit `http://` URLs into the plain-HTTPS
branch so TransportAttempt.scheme stays in {ssh, https} and the downloader
contract is consistent until #700 lands a first-class HTTP transport
- github_downloader: gate the [!] "Protocol fallback" warning on actual
scheme change (ssh<->https) rather than label change, so an auth downgrade
inside a single protocol is not misreported as a protocol switch
- pipeline / request / context / commands: switch
`allow_protocol_fallback` to `Optional[bool] = None` end-to-end so
programmatic callers (non-CLI) keep the documented "None => read
APM_ALLOW_PROTOCOL_FALLBACK env" behavior
- test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow)
All 4061 unit tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>Uh oh!
There was an error while loading. Please reload this page.
…ade (#780) (#781) * feat(transport): TransportSelector + strict-by-default transport (#778) Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(transport): wave-3 unit + integration matrix; fix per-attempt clone env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(transport): document strict-by-default transport selection (#778) Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert accidental uv.lock churn (no dependency change in this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+changelog: address copilot review feedback (#779) Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #779 review feedback - docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13 (per maintainer review comment) - transport_selection: collapse explicit `http://` URLs into the plain-HTTPS branch so TransportAttempt.scheme stays in {ssh, https} and the downloader contract is consistent until #700 lands a first-class HTTP transport - github_downloader: gate the [!] "Protocol fallback" warning on actual scheme change (ssh<->https) rather than label change, so an auth downgrade inside a single protocol is not misreported as a protocol switch - pipeline / request / context / commands: switch `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so programmatic callers (non-CLI) keep the documented "None => read APM_ALLOW_PROTOCOL_FALLBACK env" behavior - test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow) All 4061 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): MARKETPLACE_PLUGIN beats HOOK_PACKAGE in detection cascade (#780) obra/superpowers (and any Claude Code plugin shipping both hooks/*.json and agents/skills/commands) was misclassified as HOOK_PACKAGE because the cascade in detect_package_type() checked _has_hook_json BEFORE the plugin-evidence branch. The plugin synthesizer (_map_plugin_artifacts) already maps hooks alongside agents/skills/commands, so MARKETPLACE_PLUGIN is a strict superset -- swapping the order means hooks still install, plus everything else that was being silently dropped. Three deliverables: A) Surgical detection fix: reorder cascade so MARKETPLACE_PLUGIN is checked before HOOK_PACKAGE. Refactored to use a new gather_detection_evidence() helper + DetectionEvidence dataclass so observability code (warnings, summaries) can reuse the same scan without breaking the detect_package_type() public signature. B) Observability: - Add HOOK_PACKAGE to the package-type label table (it was missing entirely -- the silent classification path). - Update MARKETPLACE_PLUGIN label to mention plugin.json OR agents/skills/commands (matches the cascade behaviour). - New CommandLogger.package_type_warn() at default visibility. - New _warn_if_classification_near_miss() helper fires when a HOOK_PACKAGE classification disagrees with directory contents (catches near-misses the order swap cannot, e.g. .claude-plugin/ dir without plugin.json). - Wired at both materialization sites (local + cached). C) Architectural follow-up tracked in plan; will file as separate issue after merge for a Visitor / Format-Discovery refactor. Tests: 17 detection tests + 9 sources-observability tests + 70 command-logger tests pass. Full unit suite (4180 tests) green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #781 review: broaden detect_package_type with .claude-plugin/ evidence Per Copilot review on PR #781: the near-miss warning helper had dead code paths once the cascade was reordered (a HOOK_PACKAGE classification implies plugin_dirs_present is empty -- otherwise it would be MARKETPLACE_PLUGIN). Two principled options were offered: simplify the helper, or broaden detection so the invariants stay consistent. Chose the latter -- it removes the inconsistency at the source. Changes: - Add .claude-plugin/ as first-class plugin evidence in DetectionEvidence (new has_claude_plugin_dir field) and in has_plugin_evidence. A Claude Code plugin without a plugin.json (manifest-less) now classifies as MARKETPLACE_PLUGIN; normalize_plugin_directory already handles the missing-manifest case (derives name from directory). - Drop _warn_if_classification_near_miss helper, its two call sites, CommandLogger.package_type_warn method, and the corresponding tests. All scenarios it covered are now handled by the cascade itself. - Add regression test test_claude_plugin_dir_alone_is_plugin_evidence asserting that .claude-plugin/ + hooks/ classifies as MARKETPLACE_PLUGIN with plugin_json_path=None (matched via directory evidence alone). - Extend test_obra_superpowers_evidence to assert has_claude_plugin_dir. Verified: 4175 unit tests pass (excluding the one pre-existing unrelated failure documented on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) * feat(transport): TransportSelector + strict-by-default transport (#778) Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(transport): wave-3 unit + integration matrix; fix per-attempt clone env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(transport): document strict-by-default transport selection (#778) Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert accidental uv.lock churn (no dependency change in this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+changelog: address copilot review feedback (#779) Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #779 review feedback - docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13 (per maintainer review comment) - transport_selection: collapse explicit `http://` URLs into the plain-HTTPS branch so TransportAttempt.scheme stays in {ssh, https} and the downloader contract is consistent until #700 lands a first-class HTTP transport - github_downloader: gate the [!] "Protocol fallback" warning on actual scheme change (ssh<->https) rather than label change, so an auth downgrade inside a single protocol is not misreported as a protocol switch - pipeline / request / context / commands: switch `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so programmatic callers (non-CLI) keep the documented "None => read APM_ALLOW_PROTOCOL_FALLBACK env" behavior - test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow) All 4061 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): MARKETPLACE_PLUGIN beats HOOK_PACKAGE in detection cascade (#780) obra/superpowers (and any Claude Code plugin shipping both hooks/*.json and agents/skills/commands) was misclassified as HOOK_PACKAGE because the cascade in detect_package_type() checked _has_hook_json BEFORE the plugin-evidence branch. The plugin synthesizer (_map_plugin_artifacts) already maps hooks alongside agents/skills/commands, so MARKETPLACE_PLUGIN is a strict superset -- swapping the order means hooks still install, plus everything else that was being silently dropped. Three deliverables: A) Surgical detection fix: reorder cascade so MARKETPLACE_PLUGIN is checked before HOOK_PACKAGE. Refactored to use a new gather_detection_evidence() helper + DetectionEvidence dataclass so observability code (warnings, summaries) can reuse the same scan without breaking the detect_package_type() public signature. B) Observability: - Add HOOK_PACKAGE to the package-type label table (it was missing entirely -- the silent classification path). - Update MARKETPLACE_PLUGIN label to mention plugin.json OR agents/skills/commands (matches the cascade behaviour). - New CommandLogger.package_type_warn() at default visibility. - New _warn_if_classification_near_miss() helper fires when a HOOK_PACKAGE classification disagrees with directory contents (catches near-misses the order swap cannot, e.g. .claude-plugin/ dir without plugin.json). - Wired at both materialization sites (local + cached). C) Architectural follow-up tracked in plan; will file as separate issue after merge for a Visitor / Format-Discovery refactor. Tests: 17 detection tests + 9 sources-observability tests + 70 command-logger tests pass. Full unit suite (4180 tests) green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #781 review: broaden detect_package_type with .claude-plugin/ evidence Per Copilot review on PR #781: the near-miss warning helper had dead code paths once the cascade was reordered (a HOOK_PACKAGE classification implies plugin_dirs_present is empty -- otherwise it would be MARKETPLACE_PLUGIN). Two principled options were offered: simplify the helper, or broaden detection so the invariants stay consistent. Chose the latter -- it removes the inconsistency at the source. Changes: - Add .claude-plugin/ as first-class plugin evidence in DetectionEvidence (new has_claude_plugin_dir field) and in has_plugin_evidence. A Claude Code plugin without a plugin.json (manifest-less) now classifies as MARKETPLACE_PLUGIN; normalize_plugin_directory already handles the missing-manifest case (derives name from directory). - Drop _warn_if_classification_near_miss helper, its two call sites, CommandLogger.package_type_warn method, and the corresponding tests. All scenarios it covered are now handled by the cascade itself. - Add regression test test_claude_plugin_dir_alone_is_plugin_evidence asserting that .claude-plugin/ + hooks/ classifies as MARKETPLACE_PLUGIN with plugin_json_path=None (matched via directory evidence alone). - Extend test_obra_superpowers_evidence to assert has_claude_plugin_dir. Verified: 4175 unit tests pass (excluding the one pre-existing unrelated failure documented on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(validation): reject shell-string command in MCP stdio entries Self-defined stdio MCP entries with `command` containing whitespace and no `args` are now rejected at parse time with a fix-it error pointing at the canonical `command: <binary>, args: [<token>, ...]` shape. Previously silently accepted; APM never split `command` on whitespace, so the loose shape mis-executed downstream. The trap surfaced via #122 (thanks @lirantal) -- users coming from the universal `mcp.json` / Claude Desktop / Cursor mental model wrote `command: "npx mcp-server-foo"` and got confused when nothing worked. Per maintainer steer ("move fast, breaking OK"): error in v1, not warn. The loose shape was never specified; silent mis-execution is worse than hard-fail with a clear fix-it. CHANGELOG entry under Changed (BREAKING). Closes#806 Refs #122 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(mcp): address PR #809 review panel (cred leak, args:[], type gate, edge cases) Addresses the APM Review Panel verdict on PR #809. Must-fix: - M1: Redact raw command in error 'Got:' framing (cli-log BLOCKER / sec HIGH). Echoing 'Got: command={self.command!r}' leaked tokens like '--token=ghp_...' to stderr / CI scrollback. Now shows only the first token plus argument count. The structured 'Did you mean:' suggestion still surfaces user input verbatim because that is the copy-paste recovery path. - M2: Use 'self.args is None' instead of 'not self.args' (arch IMPORTANT). Explicit 'args: []' is a deliberate 'no extra args' signal (e.g., paired with '/opt/My App/server') and must be accepted -- 'not []' incorrectly evaluated truthy and rejected legitimate input in a BREAKING change. Should-fix: - S1: Whitespace-only command produces a dedicated 'empty or whitespace-only' error instead of the degenerate fix-it 'Did you mean: command: , args: []' (arch + devx IMPORTANT). - S2: Type gate for non-str command (sec HIGH). YAML 'command: ["npx", "-y", "x"]' previously bypassed the isinstance guard silently and crashed downstream in validate_path_segments with an unhandled AttributeError. - S3: Document rule 4 in manifest-schema.md section 4.2.3 (devx IMPORTANT). Spec and code ship together. Adds 4 regression tests covering each fix. Removes the stray space before '?' in the fix-it suggestion (cli-log NIT 8). Follow-ups (not in this PR, to be filed as issues): - Redact 'command' in MCPDependency.__repr__ (sec MEDIUM, pre-existing) - Forward MCPDependency validation errors from plugin parser to DiagnosticCollector (cli-log IMPORTANT) - Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT) - Shell-metachar warning for stdio command (sec LOW) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(mcp): address PR #809 follow-up tickets in same PR Per user direction, fold all four CEO-classified follow-up items into this PR rather than deferring to separate issues. FU1 -- Redact 'command' in MCPDependency.__repr__ (sec MEDIUM) Pre-existing leak: __repr__ echoed 'command={self.command!r}' verbatim while carefully redacting env and headers. Now shows only the first whitespace-separated token, mirroring the M1 fix. FU2 -- Surface plugin-parser MCP validation warnings (cli-log IMPORTANT) The 'apm' stdlib logger has no handlers configured, so logger.warning calls in plugin_parser were silently dropped. Added _surface_warning helper that routes through both stdlib logger AND _rich_warning so invalid MCP servers are visible without --verbose. Applied to the validation-error catch site and the no-command/no-url skip. FU3 -- Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT) The original 350-char single-line ValueError defeated terminal URL detection and the newspaper test. Restructured to: 'command' contains whitespace in MCP dependency '<name>'. Rule: ... Got: command='<first>' (N additional args) Fix: command: <first> args: [...] See: https://... URL now sits on its own line for click-through; field/rule/got/fix/see pattern is scannable per the cli-logging-ux skill's newspaper test. FU4 -- Shell-metachar warning for stdio command (sec LOW, defense-in-depth) Extended _warn_shell_metachars(env, logger) to optionally check 'command' as well, so 'command: "npx|curl evil.com"' (no whitespace, passes the rejection guard) still triggers a warning that MCP stdio servers run via execve with no shell. Hooked into the --mcp install path via entry.get('command'). Architectural improvement (LOC budget): Adding the command-checking branch pushed install.py over the 1525 invariant ceiling. Per the python-architecture skill's guidance ('don't trim cosmetically -- modularize'), extracted the F5 SSRF helper, F7 shell-metachar helper, _is_internal_or_metadata_host, _SHELL_METACHAR_TOKENS, and _METADATA_HOSTS into a new dedicated module: apm_cli/install/mcp_warnings.py. install.py back-binds the symbols at module scope so existing test patches against apm_cli.commands.install._warn_* keep working unchanged. install.py: 1530 -> 1441 LOC (84 under budget, room to breathe). Tests: 4715/4715 unit + console pass (excludes the known pre-existing test_user_scope_skips_workspace_runtimes failure on main). New regression tests: - test_validate_stdio_error_uses_multiline_cargo_style_format - test_repr_redacts_command_to_avoid_leaking_credentials Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… (#779) * feat(transport): TransportSelector + strict-by-default transport (#778) Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(transport): wave-3 unit + integration matrix; fix per-attempt clone env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(transport): document strict-by-default transport selection (#778) Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert accidental uv.lock churn (no dependency change in this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+changelog: address copilot review feedback (#779) Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #779 review feedback - docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13 (per maintainer review comment) - transport_selection: collapse explicit `http://` URLs into the plain-HTTPS branch so TransportAttempt.scheme stays in {ssh, https} and the downloader contract is consistent until #700 lands a first-class HTTP transport - github_downloader: gate the [!] "Protocol fallback" warning on actual scheme change (ssh<->https) rather than label change, so an auth downgrade inside a single protocol is not misreported as a protocol switch - pipeline / request / context / commands: switch `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so programmatic callers (non-CLI) keep the documented "None => read APM_ALLOW_PROTOCOL_FALLBACK env" behavior - test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow) All 4061 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ade (#780) (#781) * feat(transport): TransportSelector + strict-by-default transport (#778) Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(transport): wave-3 unit + integration matrix; fix per-attempt clone env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(transport): document strict-by-default transport selection (#778) Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert accidental uv.lock churn (no dependency change in this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+changelog: address copilot review feedback (#779) Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #779 review feedback - docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13 (per maintainer review comment) - transport_selection: collapse explicit `http://` URLs into the plain-HTTPS branch so TransportAttempt.scheme stays in {ssh, https} and the downloader contract is consistent until #700 lands a first-class HTTP transport - github_downloader: gate the [!] "Protocol fallback" warning on actual scheme change (ssh<->https) rather than label change, so an auth downgrade inside a single protocol is not misreported as a protocol switch - pipeline / request / context / commands: switch `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so programmatic callers (non-CLI) keep the documented "None => read APM_ALLOW_PROTOCOL_FALLBACK env" behavior - test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow) All 4061 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): MARKETPLACE_PLUGIN beats HOOK_PACKAGE in detection cascade (#780) obra/superpowers (and any Claude Code plugin shipping both hooks/*.json and agents/skills/commands) was misclassified as HOOK_PACKAGE because the cascade in detect_package_type() checked _has_hook_json BEFORE the plugin-evidence branch. The plugin synthesizer (_map_plugin_artifacts) already maps hooks alongside agents/skills/commands, so MARKETPLACE_PLUGIN is a strict superset -- swapping the order means hooks still install, plus everything else that was being silently dropped. Three deliverables: A) Surgical detection fix: reorder cascade so MARKETPLACE_PLUGIN is checked before HOOK_PACKAGE. Refactored to use a new gather_detection_evidence() helper + DetectionEvidence dataclass so observability code (warnings, summaries) can reuse the same scan without breaking the detect_package_type() public signature. B) Observability: - Add HOOK_PACKAGE to the package-type label table (it was missing entirely -- the silent classification path). - Update MARKETPLACE_PLUGIN label to mention plugin.json OR agents/skills/commands (matches the cascade behaviour). - New CommandLogger.package_type_warn() at default visibility. - New _warn_if_classification_near_miss() helper fires when a HOOK_PACKAGE classification disagrees with directory contents (catches near-misses the order swap cannot, e.g. .claude-plugin/ dir without plugin.json). - Wired at both materialization sites (local + cached). C) Architectural follow-up tracked in plan; will file as separate issue after merge for a Visitor / Format-Discovery refactor. Tests: 17 detection tests + 9 sources-observability tests + 70 command-logger tests pass. Full unit suite (4180 tests) green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #781 review: broaden detect_package_type with .claude-plugin/ evidence Per Copilot review on PR #781: the near-miss warning helper had dead code paths once the cascade was reordered (a HOOK_PACKAGE classification implies plugin_dirs_present is empty -- otherwise it would be MARKETPLACE_PLUGIN). Two principled options were offered: simplify the helper, or broaden detection so the invariants stay consistent. Chose the latter -- it removes the inconsistency at the source. Changes: - Add .claude-plugin/ as first-class plugin evidence in DetectionEvidence (new has_claude_plugin_dir field) and in has_plugin_evidence. A Claude Code plugin without a plugin.json (manifest-less) now classifies as MARKETPLACE_PLUGIN; normalize_plugin_directory already handles the missing-manifest case (derives name from directory). - Drop _warn_if_classification_near_miss helper, its two call sites, CommandLogger.package_type_warn method, and the corresponding tests. All scenarios it covered are now handled by the cascade itself. - Add regression test test_claude_plugin_dir_alone_is_plugin_evidence asserting that .claude-plugin/ + hooks/ classifies as MARKETPLACE_PLUGIN with plugin_json_path=None (matched via directory evidence alone). - Extend test_obra_superpowers_evidence to assert has_claude_plugin_dir. Verified: 4175 unit tests pass (excluding the one pre-existing unrelated failure documented on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) * feat(transport): TransportSelector + strict-by-default transport (#778) Implements the core decision engine for issue #778 'transport selection v1'. Strict-by-default semantics replace today's silent cross-protocol fallback: explicit ssh:// and https:// dependencies no longer downgrade to a different protocol, and shorthand (owner/repo) consults git insteadOf rewrites before defaulting to HTTPS. This commit ships Waves 1+2 of the transport-selection plan (per session plan): - new module src/apm_cli/deps/transport_selection.py with ProtocolPreference, TransportAttempt/TransportPlan dataclasses, GitConfigInsteadOfResolver, and TransportSelector that returns a typed, strict-by-default plan - DependencyReference grows explicit_scheme so the selector can distinguish user-stated transport from shorthand - _clone_with_fallback in github_downloader.py now iterates the selector plan; per-attempt URL building stays in the orchestrator - InstallContext / InstallRequest / pipeline / Service threaded with protocol_pref + allow_protocol_fallback so CLI args reach the downloader - apm install gains --ssh / --https (mutually exclusive) and --allow-protocol-fallback flags; honours APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars - two pre-existing tests in test_auth_scoping.py asserted the legacy permissive chain; updated to assert the new strict contract and added a coverage test for the allow_fallback escape hatch Tests: 4029 unit tests pass. Test matrix + integration tests + docs land in subsequent commits per Waves 3-5. Refs #778, #328, #661 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(transport): wave-3 unit + integration matrix; fix per-attempt clone env Wave 2 panel gate (code-review subagent) flagged that GitHubPackageDownloader._clone_with_fallback decided the clone env (locked-down vs relaxed) ONCE per dependency from has_token. Under allow_fallback=True the plan can mix attempts of different use_token values, so SSH and plain-HTTPS attempts in a mixed chain were running with GIT_ASKPASS=echo + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1, breaking ssh-agent passphrase prompts and git credential helpers. Fix the env per attempt; address an adjacent contract bug; add tests. * github_downloader._clone_with_fallback: replace the per-dep clone_env with a per-attempt _env_for() helper so only token-bearing attempts get the locked-down env. * github_downloader._build_repo_url: treat token="" as an explicit "no token" sentinel so plain-HTTPS attempts in a mixed chain genuinely run without embedded credentials, letting credential helpers (gh auth, Keychain) supply auth. Orchestrator passes "" instead of None for use_token=False attempts. * transport_selection.GitConfigInsteadOfResolver: wrap the lazy insteadOf-rewrite cache in a threading.Lock so parallel downloads can't double-populate. Tests: * tests/unit/test_transport_selection.py (NEW, 30 tests): 14-row selection matrix (explicit-strict, shorthand+insteadOf, shorthand defaults, CLI prefs, allow_fallback chain, env helpers); resolver caching; "must use normal env" contract. * tests/unit/test_auth_scoping.py: new test_allow_fallback_env_is_per_attempt_not_per_dep regression asserts auth-HTTPS gets locked-down env, SSH and plain-HTTPS get relaxed env, and plain-HTTPS does not embed the token in the URL. * tests/integration/test_transport_selection_integration.py (NEW, 7 tests): 2 always-on cases (public shorthand HTTPS; explicit https:// strict); 5 SSH-required cases (explicit ssh:// strict, bad-host no-fallback, insteadOf override, APM_GIT_PROTOCOL=ssh env, allow_fallback rescue). Gated on APM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key. * tests/fixtures/gitconfig_insteadof_to_ssh (NEW): minimal gitconfig used by the integration test for the insteadOf-honored case. * scripts/test-integration.sh: added "Transport Selection" block so the integration suite runs in CI. Full unit suite: 4061 passed (was 4029; +32 net new tests). Refs #778, #661, #328 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(transport): document strict-by-default transport selection (#778) Update the four user-facing surfaces affected by the new TransportSelector contract: * docs/src/content/docs/guides/dependencies.md: - New "Transport selection (SSH vs HTTPS)" section: breaking-change callout with the rescue env var, selection matrix, insteadOf example, --ssh / --https / APM_GIT_PROTOCOL overrides, and the --allow-protocol-fallback escape hatch. - Soften the existing "Custom ports preserved" sentence (cross-protocol retries are now opt-in). - Update the "Other Git Hosts" SSH bullet: SSH is no longer a silent fallback; point at explicit URLs or insteadOf. * docs/src/content/docs/getting-started/authentication.md: - Rewrite the "SSH connection hangs" troubleshooting entry: remove the now-incorrect "tries SSH then falls back to HTTPS" framing. - New "Choosing transport (SSH vs HTTPS)" section with a pointer to dependencies.md for the full transport contract. * docs/src/content/docs/reference/cli-commands.md: - Document --ssh / --https / --allow-protocol-fallback on apm install, plus APM_GIT_PROTOCOL and APM_ALLOW_PROTOCOL_FALLBACK env vars. * packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4 mirror): - Same transport contract in skill-resource voice with three runnable snippets and a selection matrix. CHANGELOG: scope new entries to `apm install` only (there is no `apm add` command in the codebase). Refs #778 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: revert accidental uv.lock churn (no dependency change in this PR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs+changelog: address copilot review feedback (#779) Two findings from the Copilot reviewer on PR #779: 1. Non-ASCII em dash introduced by this PR in the modified `Fields:` line of guides/dependencies.md: replace with `--`. The other non-ASCII chars Copilot flagged in the file (lines ~150-160 of the "nested groups" warning block) are pre-existing and out of scope for this PR. 2. CHANGELOG entries for the new transport-selection feature were too long and bundled multiple concerns into one bullet. Split into one tighter BREAKING entry plus two single-purpose Added entries (initial-protocol flags; fallback escape hatch). Each ends in `(#778)`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #779 review feedback - docs(dependencies): pin BREAKING-change caution panel to APM 0.8.13 (per maintainer review comment) - transport_selection: collapse explicit `http://` URLs into the plain-HTTPS branch so TransportAttempt.scheme stays in {ssh, https} and the downloader contract is consistent until #700 lands a first-class HTTP transport - github_downloader: gate the [!] "Protocol fallback" warning on actual scheme change (ssh<->https) rather than label change, so an auth downgrade inside a single protocol is not misreported as a protocol switch - pipeline / request / context / commands: switch `allow_protocol_fallback` to `Optional[bool] = None` end-to-end so programmatic callers (non-CLI) keep the documented "None => read APM_ALLOW_PROTOCOL_FALLBACK env" behavior - test_auth_scoping: ASCII-only docstring (replace one stray Unicode arrow) All 4061 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): MARKETPLACE_PLUGIN beats HOOK_PACKAGE in detection cascade (#780) obra/superpowers (and any Claude Code plugin shipping both hooks/*.json and agents/skills/commands) was misclassified as HOOK_PACKAGE because the cascade in detect_package_type() checked _has_hook_json BEFORE the plugin-evidence branch. The plugin synthesizer (_map_plugin_artifacts) already maps hooks alongside agents/skills/commands, so MARKETPLACE_PLUGIN is a strict superset -- swapping the order means hooks still install, plus everything else that was being silently dropped. Three deliverables: A) Surgical detection fix: reorder cascade so MARKETPLACE_PLUGIN is checked before HOOK_PACKAGE. Refactored to use a new gather_detection_evidence() helper + DetectionEvidence dataclass so observability code (warnings, summaries) can reuse the same scan without breaking the detect_package_type() public signature. B) Observability: - Add HOOK_PACKAGE to the package-type label table (it was missing entirely -- the silent classification path). - Update MARKETPLACE_PLUGIN label to mention plugin.json OR agents/skills/commands (matches the cascade behaviour). - New CommandLogger.package_type_warn() at default visibility. - New _warn_if_classification_near_miss() helper fires when a HOOK_PACKAGE classification disagrees with directory contents (catches near-misses the order swap cannot, e.g. .claude-plugin/ dir without plugin.json). - Wired at both materialization sites (local + cached). C) Architectural follow-up tracked in plan; will file as separate issue after merge for a Visitor / Format-Discovery refactor. Tests: 17 detection tests + 9 sources-observability tests + 70 command-logger tests pass. Full unit suite (4180 tests) green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #781 review: broaden detect_package_type with .claude-plugin/ evidence Per Copilot review on PR #781: the near-miss warning helper had dead code paths once the cascade was reordered (a HOOK_PACKAGE classification implies plugin_dirs_present is empty -- otherwise it would be MARKETPLACE_PLUGIN). Two principled options were offered: simplify the helper, or broaden detection so the invariants stay consistent. Chose the latter -- it removes the inconsistency at the source. Changes: - Add .claude-plugin/ as first-class plugin evidence in DetectionEvidence (new has_claude_plugin_dir field) and in has_plugin_evidence. A Claude Code plugin without a plugin.json (manifest-less) now classifies as MARKETPLACE_PLUGIN; normalize_plugin_directory already handles the missing-manifest case (derives name from directory). - Drop _warn_if_classification_near_miss helper, its two call sites, CommandLogger.package_type_warn method, and the corresponding tests. All scenarios it covered are now handled by the cascade itself. - Add regression test test_claude_plugin_dir_alone_is_plugin_evidence asserting that .claude-plugin/ + hooks/ classifies as MARKETPLACE_PLUGIN with plugin_json_path=None (matched via directory evidence alone). - Extend test_obra_superpowers_evidence to assert has_claude_plugin_dir. Verified: 4175 unit tests pass (excluding the one pre-existing unrelated failure documented on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(validation): reject shell-string command in MCP stdio entries Self-defined stdio MCP entries with `command` containing whitespace and no `args` are now rejected at parse time with a fix-it error pointing at the canonical `command: <binary>, args: [<token>, ...]` shape. Previously silently accepted; APM never split `command` on whitespace, so the loose shape mis-executed downstream. The trap surfaced via #122 (thanks @lirantal) -- users coming from the universal `mcp.json` / Claude Desktop / Cursor mental model wrote `command: "npx mcp-server-foo"` and got confused when nothing worked. Per maintainer steer ("move fast, breaking OK"): error in v1, not warn. The loose shape was never specified; silent mis-execution is worse than hard-fail with a clear fix-it. CHANGELOG entry under Changed (BREAKING). Closes#806 Refs #122 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(mcp): address PR #809 review panel (cred leak, args:[], type gate, edge cases) Addresses the APM Review Panel verdict on PR #809. Must-fix: - M1: Redact raw command in error 'Got:' framing (cli-log BLOCKER / sec HIGH). Echoing 'Got: command={self.command!r}' leaked tokens like '--token=ghp_...' to stderr / CI scrollback. Now shows only the first token plus argument count. The structured 'Did you mean:' suggestion still surfaces user input verbatim because that is the copy-paste recovery path. - M2: Use 'self.args is None' instead of 'not self.args' (arch IMPORTANT). Explicit 'args: []' is a deliberate 'no extra args' signal (e.g., paired with '/opt/My App/server') and must be accepted -- 'not []' incorrectly evaluated truthy and rejected legitimate input in a BREAKING change. Should-fix: - S1: Whitespace-only command produces a dedicated 'empty or whitespace-only' error instead of the degenerate fix-it 'Did you mean: command: , args: []' (arch + devx IMPORTANT). - S2: Type gate for non-str command (sec HIGH). YAML 'command: ["npx", "-y", "x"]' previously bypassed the isinstance guard silently and crashed downstream in validate_path_segments with an unhandled AttributeError. - S3: Document rule 4 in manifest-schema.md section 4.2.3 (devx IMPORTANT). Spec and code ship together. Adds 4 regression tests covering each fix. Removes the stray space before '?' in the fix-it suggestion (cli-log NIT 8). Follow-ups (not in this PR, to be filed as issues): - Redact 'command' in MCPDependency.__repr__ (sec MEDIUM, pre-existing) - Forward MCPDependency validation errors from plugin parser to DiagnosticCollector (cli-log IMPORTANT) - Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT) - Shell-metachar warning for stdio command (sec LOW) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(mcp): address PR #809 follow-up tickets in same PR Per user direction, fold all four CEO-classified follow-up items into this PR rather than deferring to separate issues. FU1 -- Redact 'command' in MCPDependency.__repr__ (sec MEDIUM) Pre-existing leak: __repr__ echoed 'command={self.command!r}' verbatim while carefully redacting env and headers. Now shows only the first whitespace-separated token, mirroring the M1 fix. FU2 -- Surface plugin-parser MCP validation warnings (cli-log IMPORTANT) The 'apm' stdlib logger has no handlers configured, so logger.warning calls in plugin_parser were silently dropped. Added _surface_warning helper that routes through both stdlib logger AND _rich_warning so invalid MCP servers are visible without --verbose. Applied to the validation-error catch site and the no-command/no-url skip. FU3 -- Multi-line Cargo-style error format (cli-log IMPORTANT / devx NIT) The original 350-char single-line ValueError defeated terminal URL detection and the newspaper test. Restructured to: 'command' contains whitespace in MCP dependency '<name>'. Rule: ... Got: command='<first>' (N additional args) Fix: command: <first> args: [...] See: https://... URL now sits on its own line for click-through; field/rule/got/fix/see pattern is scannable per the cli-logging-ux skill's newspaper test. FU4 -- Shell-metachar warning for stdio command (sec LOW, defense-in-depth) Extended _warn_shell_metachars(env, logger) to optionally check 'command' as well, so 'command: "npx|curl evil.com"' (no whitespace, passes the rejection guard) still triggers a warning that MCP stdio servers run via execve with no shell. Hooked into the --mcp install path via entry.get('command'). Architectural improvement (LOC budget): Adding the command-checking branch pushed install.py over the 1525 invariant ceiling. Per the python-architecture skill's guidance ('don't trim cosmetically -- modularize'), extracted the F5 SSRF helper, F7 shell-metachar helper, _is_internal_or_metadata_host, _SHELL_METACHAR_TOKENS, and _METADATA_HOSTS into a new dedicated module: apm_cli/install/mcp_warnings.py. install.py back-binds the symbols at module scope so existing test patches against apm_cli.commands.install._warn_* keep working unchanged. install.py: 1530 -> 1441 LOC (84 under budget, room to breathe). Tests: 4715/4715 unit + console pass (excludes the known pre-existing test_user_scope_skips_workspace_runtimes failure on main). New regression tests: - test_validate_stdio_error_uses_multiline_cargo_style_format - test_repr_redacts_command_to_avoid_leaking_credentials Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Closes#778. Closes#328. Follow-up to #661 / #665.
Independent of #700 (HTTP-allow validation) — zero overlap; either can merge first. See "Independence from #700" below.
This PR implements Transport Selection v1: APM now distinguishes "user expressed a transport preference" from "user expressed none" instead of running the same permissive HTTPS-then-SSH chain for everything.
What changes for users
ssh://host/repo.githttps://host/repo.gitowner/reposhorthand, nogit config insteadOfowner/reposhorthand,git config url.git@host:.insteadOf https://host/git clone owner/repobehavior on that machine.APM_ALLOW_PROTOCOL_FALLBACK=1(or--allow-protocol-fallback). When fallback runs, a[!]warning names both protocols.Migration / non-breaking transition for current users
This is a
### Changed (BREAKING)entry, but the rescue is one env var. The PR was scoped explicitly so a current user with a CI pipeline that depended on the silent-fallback can keep working with a single line:The CHANGELOG entry leads with this, the new docs page leads with this, and the error message that triggers when a strict clone fails names both the URL and the rescue env var.
New CLI surface
On
apm install:--ssh/--https— pick the initial transport for shorthand URLs (mutually exclusive). Explicit-scheme URLs ignore CLI prefs.--allow-protocol-fallback— opt back into the legacy permissive chain.Env vars (apply globally, picked up by both CLI and programmatic callers):
APM_GIT_PROTOCOL=ssh|httpsAPM_ALLOW_PROTOCOL_FALLBACK=1Independence from #700
src/apm_cli/install/validation.py+apm.ymlparser).src/apm_cli/deps/github_downloader.py+ newtransport_selection.py+ CLI flags).git diff --stat main..HEADshows zero edits in feat: support allow-insecure HTTP dependencies #700's territory. Trivial CHANGELOG conflict possible; both can merge in either order.Architecture
New module
src/apm_cli/deps/transport_selection.py(~310 LOC) extracts the decision from the orchestrator:ProtocolPreferenceenum (NONE/SSH/HTTPS).TransportAttemptfrozen dataclass — pure data, comparable with==, no closures.TransportPlanfrozen dataclass —(attempts, strict, fallback_hint).InsteadOfResolverProtocol; defaultGitConfigInsteadOfResolvershells outgit config --get-regexp '^url\..*\.insteadof$'. Lazy load, double-checked locking around the cache.TransportSelector.select(dep_ref, cli_pref, allow_fallback, has_token) -> TransportPlan.GitHubPackageDownloader._clone_with_fallbacknow consumes aTransportPlanand iteratesplan.attemptsinstead of running the hardcoded chain. URL building stays in the orchestrator (existing_build_repo_urlhonorsuse_ssh,token).Critical invariant kept:
GitConfigInsteadOfResolver._load_rewrites()runs with the process's normal env (NOT the downloader's locked-downgit_envwhich setsGIT_CONFIG_GLOBAL=/dev/null). Otherwise issue #328 would be unfixable because the user's insteadOf config would be invisible.Per-attempt clone env (Wave 2 panel finding, fixed in commit
724f8e7)The first cut decided the clone env (locked-down vs relaxed) once per dependency from
has_token. Under--allow-protocol-fallbackthe plan can mix attempts of differentuse_tokenvalues, so SSH + plain-HTTPS attempts in a mixed chain were running withGIT_ASKPASS=echo+GIT_CONFIG_GLOBAL=/dev/null+GIT_CONFIG_NOSYSTEM=1— breaking ssh-agent passphrase prompts, gh auth, Keychain.Fix:
_env_for(attempt.use_token)per-attempt closure: only token-bearing attempts get the locked-down env._build_repo_url(token=None)fell back toself.github_token, so "plain HTTPS" attempts in a mixed chain still embedded the token in the URL — violating theuse_token=Falsecontract. Fixed via empty-string sentinel:token=""means explicit "no token" and is what the orchestrator now passes for non-token attempts.test_allow_fallback_env_is_per_attempt_not_per_depdrives a 3-attempt mixed chain and asserts auth-HTTPS=locked-down, SSH=relaxed, plain-HTTPS=relaxed-and-tokenless.Also fixed concurrency:
GitConfigInsteadOfResolvercache lazy-load now uses double-checked locking withthreading.Lockso parallel downloads can't double-populate.Tests
tests/unit/test_transport_selection.py(NEW)tests/unit/test_auth_scoping.py(regression)tests/integration/test_transport_selection_integration.py(NEW)APM_GIT_PROTOCOL=ssh, allow_fallback rescue). Gated onAPM_RUN_INTEGRATION_TESTS=1; SSH cases auto-skip if no key.scripts/test-integration.shFull unit suite: 4061 passed (was 4029; +32 net new).
APM Review Panel sign-off
Synthesized review across the panel personas (per
.github/skills/apm-review-panel/).python-architect(Wave 0 + 2): VERDICT: ship. All 6 mandatory edits from the Wave 0 verdict applied. Two deliberate scope-shrinks acknowledged: (1)_clone_with_fallbacknot renamed to_clone_with_transport(5+ tests patch it by name); (2)list_remote_refs(~line 876 ingithub_downloader.py) still hardcodes HTTPS — flagged as fast-follow, not in scope.supply-chain-security-expert: Strict-by-default closes the induced-downgrade hazard #661 was filed for. Token-leak guard in plain-HTTPS attempts is a meaningful additional hardening discovered during the fix. Escape hatch is### Changed (BREAKING)-documented and gated by an explicit env var, not implicit. APPROVED.auth-expert:_env_forper-attempt is the correct contract — token attempts must run locked-down, non-token attempts must let credential helpers (gh auth, Keychain, ssh-agent) work. Thetoken=""sentinel is documented in the source. Resolver_load_rewrites()running with normal env (notgit_env) is correct and is the ONLY way to honor userinsteadOfconfig. APPROVED.devx-ux-expert: Migration story is one env var, named in the error message that triggers the broken case. Three new flags (--ssh,--https,--allow-protocol-fallback) plus three new env vars (APM_GIT_PROTOCOL,APM_ALLOW_PROTOCOL_FALLBACK) document cleanly in CLI ref + dependencies guide; warning on permitted fallback names both protocols. Migration-UX deviation noted: underallow_fallback=Truewe let explicit-scheme URLs fall through to the shorthand chain rather than staying strict. Architect's Wave 0 verdict said "explicit_scheme always strict." Deviation kept on purpose — users explicitly opting into fallback mode are saying "I want today's behavior" and should get it for explicit URLs too. APPROVED with deviation acknowledged.cli-logging-expert:[!]warning format on permitted fallback matchesSTATUS_SYMBOLSconvention. Errors on strict-mode failure name the URL and the rescue env var. Per-(url, from-to) dedup avoids warning fatigue. APPROVED.oss-growth-hacker: Closing #328 (community pain on shorthand+SSH) is the highest-leverage win in this PR — closes a frequently-cited friction. Strict default also positions APM as a safer choice vs alternatives. APPROVED.apm-ceo(final call): SHIP. Pre-1.0 breaking change with a one-flag rescue is consistent withCONTRIBUTING.mdphilosophy. The combination — closes#328, fixes#661 hazard, ships independently of #700 — is high-leverage. Wave 5 sign-off granted.Risks & known follow-ups (out of scope for this PR)
list_remote_refs(~line 876 ofgithub_downloader.py) still hardcodes HTTPS. Architect-flagged fast-follow; tracked separately.git config --get-regexprequires git ≥ 1.8.5; older git silently degrades to no insteadOf detection (today's behavior).Refs #778, #328, #661, #665, #700, #777