Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed (BREAKING)

- Strict-by-default transport selection: explicit `ssh://`/`https://` URLs no longer silently fall back to the other protocol; shorthand consults `git config url.<base>.insteadOf` and otherwise defaults to HTTPS. Set `APM_ALLOW_PROTOCOL_FALLBACK=1` (or pass `--allow-protocol-fallback`) to restore the legacy permissive chain; cross-protocol retries then emit a `[!]` warning. Closes #328 (#778)

Comment thread
danielmeppiel marked this conversation as resolved.
### Added

- `apm install --ssh` / `--https` flags and `APM_GIT_PROTOCOL=ssh|https` env to pick the initial transport for shorthand dependencies (#778)
- `apm install --allow-protocol-fallback` flag and `APM_ALLOW_PROTOCOL_FALLBACK=1` env as the migration escape hatch for cross-protocol fallback (#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).

### Fixed
Expand Down
35 changes: 33 additions & 2 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,37 @@ gh auth login # GitHub CLI

### SSH connection hangs on corporate/VPN networks

When no token is available, APM tries SSH before falling back to plain HTTPS. Firewalls that silently drop SSH packets (port 22) can make `apm install` appear to hang. APM sets `GIT_SSH_COMMAND="ssh -o ConnectTimeout=30"` so SSH attempts fail within 30 seconds and the fallback proceeds to HTTPS with git credential helpers.
When APM clones over SSH (because the dependency is an SSH URL, the user
passed `--ssh`, `git config url.<base>.insteadOf` rewrites to SSH, or
`--allow-protocol-fallback` is in effect), firewalls that silently drop SSH
packets (port 22) can make `apm install` appear to hang. APM sets
`GIT_SSH_COMMAND="ssh -o ConnectTimeout=30"` so SSH attempts fail within 30
seconds.

If you already set `GIT_SSH_COMMAND` (e.g., for a custom key), APM appends `-o ConnectTimeout=30` unless `ConnectTimeout` is already present in your value.
If you already set `GIT_SSH_COMMAND` (e.g., for a custom key), APM appends
`-o ConnectTimeout=30` unless `ConnectTimeout` is already present in your
value.

If SSH is unreachable from your network, force HTTPS:

```bash
apm install --https
export APM_GIT_PROTOCOL=https
```

## Choosing transport (SSH vs HTTPS)

Authentication and transport are independent decisions:

- **HTTPS** uses the token resolution chain documented above. APM resolves a
token per `(host, org)` and embeds it in the clone URL.
- **SSH** uses your existing ssh-agent and `~/.ssh/config`. APM does not
select keys or override agent behavior -- whatever `git clone` would do
on the same machine, APM does.

APM picks the transport per dependency using a strict contract (explicit
URL scheme honored exactly; shorthand uses HTTPS unless
`git config url.<base>.insteadOf` rewrites it to SSH). For the full
selection matrix, the `--ssh` / `--https` flags, the `APM_GIT_PROTOCOL`
env var, and the `--allow-protocol-fallback` escape hatch, see
[Dependencies: Transport selection](../../guides/dependencies/#transport-selection-ssh-vs-https).
68 changes: 66 additions & 2 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ dependencies:
ref: v1.0
```

Fields: `git` (required), `path`, `ref`, `alias` (all optional). The `git` value is any HTTPS or SSH clone URL. Custom ports are preserved across protocols — if the SSH clone fails and APM falls back to HTTPS, the same port number is reused (so `ssh://host:7999/...` falls back to `https://host:7999/...`).
Fields: `git` (required), `path`, `ref`, `alias` (all optional). The `git` value is any HTTPS or SSH clone URL. Explicit URL schemes are honored exactly -- see [Transport selection](#transport-selection-ssh-vs-https) for the full contract. Custom ports are preserved across every attempt (including any cross-protocol fallback enabled with `--allow-protocol-fallback`), so `ssh://host:7999/...` retried over HTTPS becomes `https://host:7999/...`.

> **Nested groups (GitLab, Gitea, etc.):** APM treats all path segments after the host as the repo path, so `gitlab.com/group/subgroup/repo` resolves to a repo at `group/subgroup/repo`. Virtual paths on simple 2-segment repos work with shorthand (`gitlab.com/owner/repo/file.prompt.md`). But for **nested-group repos + virtual paths**, use the object format — the shorthand is ambiguous:
>
Expand Down Expand Up @@ -426,6 +426,70 @@ apm install --trust-transitive-mcp

Run `apm install --dry-run` to preview MCP dependency configuration without writing any files. Self-defined deps are validated for required fields and transport values; overlay deps are loaded as-is and unknown fields are ignored.

## Transport selection (SSH vs HTTPS)

APM picks SSH or HTTPS per dependency using a strict, predictable contract.

:::caution[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**: explicit URL schemes are honored exactly,
and shorthand uses HTTPS unless `git config url.<base>.insteadOf` rewrites it
to SSH. To restore the legacy permissive chain temporarily (e.g. while
migrating CI), set `APM_ALLOW_PROTOCOL_FALLBACK=1` or pass
`--allow-protocol-fallback`.
:::

| Dependency form | What APM tries |
|-----------------|----------------|
| `ssh://...` or `git@host:...` | SSH only |
| `https://...` or `http://...` | HTTPS only |
| Shorthand (`owner/repo`, `host/owner/repo`) with `git config url.<base>.insteadOf` rewriting to SSH | SSH only |
| Shorthand without a matching `insteadOf` rewrite | HTTPS only |

A failed clone fails loudly, naming the URL and the protocol attempted. APM
no longer downgrades `ssh://` to HTTPS or vice-versa.

### Honoring `git config insteadOf`

If your machine rewrites HTTPS to SSH for a host, APM matches `git clone`'s
behavior on that machine. Example:

```bash
git config --global url."git@github.com:".insteadOf "https://github.com/"
apm install owner/repo # APM clones over SSH
```

No CLI flag is needed. `insteadOf` is consulted only for shorthand
dependencies; explicit URLs in `apm.yml` are not rewritten.

### Forcing the initial protocol for shorthand

```bash
apm install owner/repo --ssh # force SSH for shorthand
apm install owner/repo --https # force HTTPS for shorthand
export APM_GIT_PROTOCOL=ssh # session default
```

`--ssh` and `--https` are mutually exclusive and apply only to shorthand
dependencies. URLs with an explicit scheme ignore them.

### Restoring the legacy permissive chain

```bash
apm install --allow-protocol-fallback
export APM_ALLOW_PROTOCOL_FALLBACK=1 # CI / migration window
```

When fallback runs, each cross-protocol retry emits a `[!]` warning naming
both protocols. Use this to unblock a pipeline while you fix the root
cause -- not as a long-term setting.

For SSH key selection (ssh-agent, `~/.ssh/config`) and HTTPS token
resolution, see
[Authentication](../../getting-started/authentication/#choosing-transport-ssh-vs-https).
For the CLI flag and env var reference, see
[`apm install`](../../reference/cli-commands/#apm-install---install-dependencies-and-deploy-local-content).

## GitHub Authentication Setup

For GitHub and GitHub Enterprise repositories, set up a personal access token:
Expand Down Expand Up @@ -465,7 +529,7 @@ If authentication fails, you'll see an error with guidance on token setup.
For non-GitHub repositories, APM delegates authentication to git — it never sends GitHub tokens to non-GitHub hosts:

- **Public repos**: Work without authentication via HTTPS
- **Private repos via SSH**: Configure SSH keys for your host — APM falls back to SSH automatically
- **Private repos via SSH**: Configure SSH keys for your host. Use an `ssh://` or `git@host:` URL, or set up `git config url.<base>.insteadOf` to rewrite shorthand to SSH (see [Transport selection](#transport-selection-ssh-vs-https))
- **Private repos via HTTPS**: Configure a [git credential helper](https://git-scm.com/docs/gitcredentials) — APM allows credential helpers for non-GitHub hosts

```bash
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ apm install [PACKAGES...] [OPTIONS]
- `--trust-transitive-mcp` - Trust self-defined MCP servers from transitive packages (skip re-declaration requirement)
- `--dev` - Add packages to [`devDependencies`](../manifest-schema/#5-devdependencies) instead of `dependencies`. Dev deps are installed locally but excluded from `apm pack --format plugin` bundles
- `-g, --global` - Install to user scope (`~/.apm/`) instead of the current project. Primitives deploy to `~/.copilot/`, `~/.claude/`, etc.
- `--ssh` - Force SSH for shorthand (`owner/repo`) dependencies. Mutually exclusive with `--https`. Ignored for URLs with an explicit scheme.
- `--https` - Force HTTPS for shorthand dependencies. Mutually exclusive with `--ssh`. Default unless `git config url.<base>.insteadOf` rewrites the candidate to SSH.
- `--allow-protocol-fallback` - Restore the legacy permissive cross-protocol fallback chain (HTTPS-then-SSH or vice-versa). Strict-by-default otherwise. Each retry emits a `[!]` warning naming both protocols.

**Transport env vars:**

| Variable | Purpose |
|----------|---------|
| `APM_GIT_PROTOCOL` | `ssh` or `https`. Default initial transport for shorthand dependencies (overridden by `--ssh` / `--https`). |
| `APM_ALLOW_PROTOCOL_FALLBACK` | Set to `1` to enable the legacy permissive chain without passing `--allow-protocol-fallback`. |

See [Dependencies: Transport selection](../../guides/dependencies/#transport-selection-ssh-vs-https) for the full selection matrix.

**Behavior:**
- `apm install` (no args): Installs **all** packages from `apm.yml` and deploys the project's own `.apm/` content
Expand Down
48 changes: 46 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ dependencies:
### Custom git ports

Non-default git ports are preserved on `https://`, `http://`, and `ssh://` URLs
and threaded through all clone attempts. When the SSH clone fails, the HTTPS
fallback reuses the same port instead of silently dropping it.
and threaded through every clone attempt (including any cross-protocol
fallback enabled with `--allow-protocol-fallback`).

- Use the `ssh://` form to specify an SSH port
(e.g. `ssh://git@host:7999/owner/repo.git`). The SCP shorthand
Expand All @@ -48,6 +48,50 @@ fallback reuses the same port instead of silently dropping it.
is set. Port is a transport detail, not part of the package identity --
the same repo reachable on different ports dedupes to one entry.

## Transport selection (SSH vs HTTPS)

Strict by default. Pick the transport up front; APM never silently retries
across protocols.

| Dependency form | What APM tries |
|-----------------|----------------|
| `ssh://...` or `git@host:...` | SSH only |
| `https://...` or `http://...` | HTTPS only |
| Shorthand with `git config url.<base>.insteadOf` rewriting to SSH | SSH only |
| Shorthand otherwise | HTTPS only |

A failed clone fails loudly, naming the URL and the protocol attempted.
Explicit URL schemes are honored exactly.

Force the initial protocol for shorthand:

```bash
apm install owner/repo --ssh # SSH for shorthand
apm install owner/repo --https # HTTPS for shorthand
export APM_GIT_PROTOCOL=ssh # session default
```

`--ssh` and `--https` are mutually exclusive and apply only to shorthand.
URLs with an explicit scheme ignore them.

Match local `git clone` behavior by configuring `insteadOf` once:

```bash
git config --global url."git@github.com:".insteadOf "https://github.com/"
apm install owner/repo # APM clones over SSH
```

Restore the legacy permissive chain (escape hatch -- not a long-term
setting):

```bash
apm install --allow-protocol-fallback
export APM_ALLOW_PROTOCOL_FALLBACK=1 # CI / migration window
```

When fallback runs, each cross-protocol retry emits a `[!]` warning naming
both protocols.

## Object form (complex cases)

```yaml
Expand Down
13 changes: 13 additions & 0 deletions scripts/test-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,19 @@ run_e2e_tests() {
exit 1
fi

# Run Transport Selection integration tests (issue #778)
# Always-on cases use HTTPS against a public repo. SSH cases auto-skip
# when no usable SSH key is available for git@github.com.
log_info "Running Transport Selection integration tests..."
echo "Command: APM_RUN_INTEGRATION_TESTS=1 pytest tests/integration/test_transport_selection_integration.py -v -s --tb=short"

if APM_RUN_INTEGRATION_TESTS=1 pytest tests/integration/test_transport_selection_integration.py -v -s --tb=short; then
log_success "Transport Selection integration tests passed!"
else
log_error "Transport Selection integration tests failed!"
exit 1
fi

# Run Azure DevOps E2E tests (requires ADO_APM_PAT)
if [[ -n "${ADO_APM_PAT:-}" ]]; then
log_info "Running Azure DevOps E2E tests..."
Expand Down
49 changes: 47 additions & 2 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import builtins
import sys
from pathlib import Path
from typing import List
from typing import List, Optional

import click

Expand Down Expand Up @@ -392,8 +392,29 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
default=False,
help="Install to user scope (~/.apm/) instead of the current project",
)
@click.option(
"--ssh",
"use_ssh",
is_flag=True,
default=False,
help="Prefer SSH transport for shorthand (owner/repo) dependencies. Mutually exclusive with --https.",
)
@click.option(
"--https",
"use_https",
is_flag=True,
default=False,
help="Prefer HTTPS transport for shorthand (owner/repo) dependencies. Mutually exclusive with --ssh.",
)
@click.option(
"--allow-protocol-fallback",
"allow_protocol_fallback",
is_flag=True,
default=False,
help="Restore the legacy permissive cross-protocol fallback chain (escape hatch for migrating users; also: APM_ALLOW_PROTOCOL_FALLBACK=1).",
)
@click.pass_context
def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, global_):
def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, global_, use_ssh, use_https, allow_protocol_fallback):
"""Install APM and MCP dependencies from apm.yml (like npm install).

This command automatically detects AI runtimes from your apm.yml scripts and installs
Expand Down Expand Up @@ -421,6 +442,24 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
is_partial = bool(packages)
logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial)

# Resolve transport selection inputs.
from ..deps.transport_selection import (
ProtocolPreference,
is_fallback_allowed,
protocol_pref_from_env,
)
if use_ssh and use_https:
_rich_error("Options --ssh and --https are mutually exclusive.", symbol="error")
sys.exit(2)
if use_ssh:
protocol_pref = ProtocolPreference.SSH
elif use_https:
protocol_pref = ProtocolPreference.HTTPS
else:
protocol_pref = protocol_pref_from_env()
# CLI flag OR env var enables fallback.
allow_protocol_fallback = allow_protocol_fallback or is_fallback_allowed()

# Resolve scope
from ..core.scope import InstallScope, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope
scope = InstallScope.USER if global_ else InstallScope.PROJECT
Expand Down Expand Up @@ -593,6 +632,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
marketplace_provenance=(
outcome.marketplace_provenance if packages and outcome else None
),
protocol_pref=protocol_pref,
allow_protocol_fallback=allow_protocol_fallback,
)
apm_count = install_result.installed_count
prompt_count = install_result.prompts_integrated
Expand Down Expand Up @@ -732,6 +773,8 @@ def _install_apm_dependencies(
auth_resolver: "AuthResolver" = None,
target: str = None,
marketplace_provenance: dict = None,
protocol_pref=None,
allow_protocol_fallback: "Optional[bool]" = None,
):
"""Thin wrapper -- builds an :class:`InstallRequest` and delegates to
:class:`apm_cli.install.service.InstallService`.
Expand Down Expand Up @@ -759,6 +802,8 @@ def _install_apm_dependencies(
auth_resolver=auth_resolver,
target=target,
marketplace_provenance=marketplace_provenance,
protocol_pref=protocol_pref,
allow_protocol_fallback=allow_protocol_fallback,
)
return InstallService().run(request)

Expand Down
Loading
Loading