Skip to content

fix: recognize ssh:// scheme in is_git_url()#3760

Open
jpshackelford wants to merge 1 commit into
mainfrom
fix-ssh-scheme-git-url-recognition
Open

fix: recognize ssh:// scheme in is_git_url()#3760
jpshackelford wants to merge 1 commit into
mainfrom
fix-ssh-scheme-git-url-recognition

Conversation

@jpshackelford

@jpshackelford jpshackelford commented Jun 16, 2026

Copy link
Copy Markdown
Member

What & why

is_git_url() did not recognize the ssh:// URL scheme, so a plugin/extension source like ssh://git@bitbucket.example.com:7999/team/repo.git failed at parse time with a misleading Unable to parse extension source, even though the equivalent scp-style spelling (git@host:team/repo.git) is recognized and proceeds to clone. This adds a one-line recognition branch so both SSH spellings behave the same.

Closes #3759.

⚠️ Scope — this is a deliberately narrow edge case

This only matters for a user who is doing all of the following:

  1. cloning a private plugin/extension repo over SSH (not HTTPS), and
  2. spelling it with the ssh:// scheme specifically (the scp-style git@host:… form already works), and
  3. has manually provisioned an SSH key into the sandbox/runtime before attaching the conversation that triggers the plugin clone.

It does not add SSH key provisioning and does not make SSH work for hosted/SaaS users who haven't injected a key. For everyone else the supported path remains an HTTPS URL with a token (and, with #3755, that token can be a ${SECRET} reference). This PR purely removes a parse-level inconsistency so an already-working SSH setup also accepts the ssh:// spelling.

Change

openhands/sdk/git/utils.py — add an ssh:// branch to is_git_url() (alongside https://, scp-style, git://, file://). normalize_git_url() and extract_repo_name() already handle the ssh:// string correctly, so no other changes are needed; the source now classifies as SourceType.GIT and flows through the normal cached-clone path (which still succeeds only if the runtime has a usable key).

Tests

tests/sdk/extensions/test_fetch.py::test_parse_ssh_scheme_urlssh://git@host:7999/team/repo.gitSourceType.GIT (regression for the "Unable to parse" failure). All 42 tests in the file pass; ruff check/format clean.


This PR was created by an AI agent (OpenHands) on behalf of @jpshackelford.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:92adf44-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-92adf44-python \
  ghcr.io/openhands/agent-server:92adf44-python

All tags pushed for this build

ghcr.io/openhands/agent-server:92adf44-golang-amd64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-golang-amd64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-golang-amd64
ghcr.io/openhands/agent-server:92adf44-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:92adf44-golang-arm64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-golang-arm64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-golang-arm64
ghcr.io/openhands/agent-server:92adf44-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:92adf44-java-amd64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-java-amd64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-java-amd64
ghcr.io/openhands/agent-server:92adf44-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:92adf44-java-arm64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-java-arm64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-java-arm64
ghcr.io/openhands/agent-server:92adf44-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:92adf44-python-amd64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-python-amd64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-python-amd64
ghcr.io/openhands/agent-server:92adf44-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:92adf44-python-arm64
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-python-arm64
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-python-arm64
ghcr.io/openhands/agent-server:92adf44-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:92adf44-golang
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-golang
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-golang
ghcr.io/openhands/agent-server:92adf44-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:92adf44-java
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-java
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-java
ghcr.io/openhands/agent-server:92adf44-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:92adf44-python
ghcr.io/openhands/agent-server:92adf44f97604795b7b5621f2e9fe682b9620011-python
ghcr.io/openhands/agent-server:fix-ssh-scheme-git-url-recognition-python
ghcr.io/openhands/agent-server:92adf44-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 92adf44-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 92adf44-python-amd64) are also available if needed

ssh://git@host[:port]/path was not recognized as a git URL, so plugin/
extension sources using the ssh:// spelling failed at parse time with
'Unable to parse extension source' -- inconsistent with the scp-style
git@host:path form, which is recognized. Add an ssh:// branch so both
spellings are classified as git sources and handed to the normal clone
path (which succeeds iff the runtime has a usable SSH key).

Closes #3759

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford jpshackelford added bug Something isn't working customer-support This item has been reported by a customer and is being tracked as a support ticket. plugins About plugins and their contents labels Jun 16, 2026 — with OpenHands AI
@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/git
   utils.py1282778%89–91, 116–118, 206–207, 214–219, 224–225, 235–240, 250–252, 280, 392
TOTAL31199854272% 

@jpshackelford jpshackelford left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating

🟢 Good taste — This is the rare bugfix that does not confuse “SSH URL parsing” with “SSH key management.” It plugs the exact classifier hole and lets the existing clone path do its job.

Improvement Opportunities

None worth raising. The data flow is clean:

  • parse_extension_source() delegates URL classification to is_git_url().
  • normalize_git_url() correctly leaves SSH URLs alone instead of vandalizing them with .git suffix rules meant for HTTP.
  • get_cache_path() / extract_repo_name() still produce a sane readable cache directory for ssh://git@host:port/team/repo.git.
  • _fetch_remote_source_with_resolution() passes the parsed URL straight into try_cached_clone_or_update(), so this does not create a second SSH code path.

Anything more here would be scope creep dressed up as thoroughness.

Style Notes

None. The new branch is explicit enough, and the test name says exactly what broke.

Testing Gaps

None for this narrow scope. The new regression test hits the parse-level failure that caused Unable to parse extension source; the downstream cached-clone path is already generic over SourceType.GIT, and I verified the trace into try_cached_clone_or_update().

Local validation:

  • uv run pytest tests/sdk/extensions/test_fetch.py -q42 passed, 5 existing warnings.
  • Required PR checks were also green when inspected.

Risk Assessment

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

The only behavior change is that ssh://... moves from “reject during extension-source parsing” to “try the existing git clone path.” That is the right failure boundary: malformed or unauthenticated SSH URLs will now fail as git clone failures, not as fake parser incompatibilities. No dependency changes, no credential provisioning, no new shell construction, and no benchmark/eval-sensitive agent behavior.

Verdict

Worth merging: This is the right one-line fix for the stated edge case. It does not pretend to solve hosted SSH credentials, and that restraint is the whole reason this PR stays clean.

Key Insight

The architecture already had a generic git clone pipeline; the bug was just that one valid Git transport spelling was left standing outside the front door.


This review was generated by an AI agent (OpenHands) on behalf of @jpshackelford.

@jpshackelford jpshackelford marked this pull request as ready for review June 16, 2026 21:49

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

The PR removes the parse-time blocker for ssh:// extension sources and preserves existing git URL behavior.

Does this PR achieve its stated goal?

Yes. I reproduced the base-branch failure where ssh://git@... extension sources raise Unable to parse extension source, then re-ran the same SDK entry points on the PR branch and saw the source classify as git with the URL preserved. I also exercised the higher-level fetch() path: on the PR branch it no longer fails at parsing and proceeds to git clone, failing only because the intentionally unreachable localhost SSH endpoint refused the connection.

Phase Result
Environment Setup make build completed successfully; dependencies installed via uv
CI Status ⚠️ 35 successful, 1 failing (PR Description Check), 1 in progress (QA Changes by OpenHands), 16 skipped at time of QA; I did not rerun CI/tests
Functional Verification ssh:// parsing and fetch-path behavior verified before/after; scp-style and HTTPS git URLs still classify as git
Functional Verification

Test 1: Extension source parsing recognizes ssh:// after the fix

Step 1 — Reproduce / establish baseline (without the fix):
Ran on origin/main:

git checkout --detach origin/main && uv run python - <<'PY'
from openhands.sdk.extensions.fetch import parse_extension_source

sources = [
    "ssh://git@bitbucket.example.com:7999/team/repo.git",
    "git@bitbucket.example.com:team/repo.git",
    "https://bitbucket.example.com/team/repo.git",
]

for source in sources:
    try:
        source_type, url = parse_extension_source(source)
        print(f"OK {source!r} -> {source_type.value} {url}")
    except Exception as exc:
        print(f"ERROR {source!r} -> {type(exc).__name__}: {exc}")
PY

Relevant output:

ERROR 'ssh://git@bitbucket.example.com:7999/team/repo.git' -> ExtensionFetchError: Unable to parse extension source: ssh://git@bitbucket.example.com:7999/team/repo.git. Expected formats: 'github:owner/repo', git URL, or local path
OK 'git@bitbucket.example.com:team/repo.git' -> git git@bitbucket.example.com:team/repo.git
OK 'https://bitbucket.example.com/team/repo.git' -> git https://bitbucket.example.com/team/repo.git

This confirms the reported bug exists on the base branch: explicit ssh:// URLs fail before reaching git handling, while related git URL forms are accepted.

Step 2 — Apply the PR's changes:
Checked out fix-ssh-scheme-git-url-recognition at 92adf44f97604795b7b5621f2e9fe682b9620011.

Step 3 — Re-run with the fix in place:
Ran the same script on the PR branch. Relevant output:

OK 'ssh://git@bitbucket.example.com:7999/team/repo.git' -> git ssh://git@bitbucket.example.com:7999/team/repo.git
OK 'git@bitbucket.example.com:team/repo.git' -> git git@bitbucket.example.com:team/repo.git
OK 'https://bitbucket.example.com/team/repo.git' -> git https://bitbucket.example.com/team/repo.git

This shows the fix works for the stated parse-level behavior and does not break the existing scp-style or HTTPS git forms.

Test 2: Higher-level extension fetch path advances past parsing

Step 1 — Reproduce / establish baseline (without the fix):
Ran on origin/main with an intentionally unreachable localhost SSH endpoint:

git checkout --detach origin/main && uv run python - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory

from openhands.sdk.extensions.fetch import fetch

source = "ssh://git@127.0.0.1:1/team/repo.git"
with TemporaryDirectory() as tmpdir:
    try:
        fetch(source, Path(tmpdir), update=False)
        print("FETCH_OK")
    except Exception as exc:
        print(f"FETCH_ERROR {type(exc).__name__}: {exc}")
PY

Relevant output:

FETCH_ERROR ExtensionFetchError: Unable to parse extension source: ssh://git@127.0.0.1:1/team/repo.git. Expected formats: 'github:owner/repo', git URL, or local path

This confirms the user-facing extension fetch path fails at parse time before the fix.

Step 2 — Apply the PR's changes:
Checked out the PR branch again.

Step 3 — Re-run with the fix in place:
Ran the same script on the PR branch. Relevant output:

Cloning repository from ssh://git@127.0.0.1:1/team/repo.git
ssh: connect to host 127.0.0.1 port 1: Connection refused
fatal: Could not read from remote repository.
FETCH_ERROR ExtensionFetchError: Failed to fetch extension from ssh://git@127.0.0.1:1/team/repo.git

This shows the parse-level error is gone and the source now flows into the normal git clone path; the remaining failure is expected because no SSH server/key-backed repo was provided.

Test 3: Git URL helpers for ssh://

Ran on the PR branch:

OPENHANDS_SUPPRESS_BANNER=1 uv run python -c 'from openhands.sdk.git.utils import extract_repo_name,is_git_url,normalize_git_url; source="ssh://git@bitbucket.example.com:7999/team/repo.git"; print(f"is_git_url={is_git_url(source)}"); print(f"normalize_git_url={normalize_git_url(source)}"); print(f"extract_repo_name={extract_repo_name(source)}")'

Output:

is_git_url=True
normalize_git_url=ssh://git@bitbucket.example.com:7999/team/repo.git
extract_repo_name=repo

This supports the PR description's claim that recognition is the missing piece while normalization and repo-name extraction already handle this URL shape.

Issues Found

None.

This QA review was generated by an AI agent (OpenHands) on behalf of the requester.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working customer-support This item has been reported by a customer and is being tracked as a support ticket. plugins About plugins and their contents

Projects

None yet

Development

Successfully merging this pull request may close these issues.

is_git_url() does not recognize the ssh:// scheme (plugin source ssh://… fails with 'Unable to parse')

3 participants