fix: recognize ssh:// scheme in is_git_url()#3760
Conversation
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>
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
jpshackelford
left a comment
There was a problem hiding this comment.
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 tois_git_url().normalize_git_url()correctly leaves SSH URLs alone instead of vandalizing them with.gitsuffix rules meant for HTTP.get_cache_path()/extract_repo_name()still produce a sane readable cache directory forssh://git@host:port/team/repo.git._fetch_remote_source_with_resolution()passes the parsed URL straight intotry_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 -q→ 42 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.
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ 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 | 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}")
PYRelevant 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}")
PYRelevant 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.
What & why
is_git_url()did not recognize thessh://URL scheme, so a plugin/extensionsourcelikessh://git@bitbucket.example.com:7999/team/repo.gitfailed at parse time with a misleadingUnable 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.
This only matters for a user who is doing all of the following:
ssh://scheme specifically (the scp-stylegit@host:…form already works), andIt 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 thessh://spelling.Change
openhands/sdk/git/utils.py— add anssh://branch tois_git_url()(alongsidehttps://, scp-style,git://,file://).normalize_git_url()andextract_repo_name()already handle thessh://string correctly, so no other changes are needed; the source now classifies asSourceType.GITand 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_url—ssh://git@host:7999/team/repo.git→SourceType.GIT(regression for the "Unable to parse" failure). All 42 tests in the file pass;ruff check/formatclean.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
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:92adf44-pythonRun
All tags pushed for this build
About Multi-Architecture Support
92adf44-python) is a multi-arch manifest supporting both amd64 and arm6492adf44-python-amd64) are also available if needed