From a71198a508d20b312cd434125a784c8b2ddfe5f7 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 09:49:52 -0600 Subject: [PATCH 01/51] extensions module, entry point stubs --- .../openhands/sdk/extensions/__init__.py | 0 .../openhands/sdk/extensions/fetch.py | 76 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 openhands-sdk/openhands/sdk/extensions/__init__.py create mode 100644 openhands-sdk/openhands/sdk/extensions/fetch.py diff --git a/openhands-sdk/openhands/sdk/extensions/__init__.py b/openhands-sdk/openhands/sdk/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openhands-sdk/openhands/sdk/extensions/fetch.py b/openhands-sdk/openhands/sdk/extensions/fetch.py new file mode 100644 index 0000000000..8209cf5f96 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/fetch.py @@ -0,0 +1,76 @@ +"""Fetching utilities for extensions.""" + +from __future__ import annotations + +from pathlib import Path + +from openhands.sdk.git.cached_repo import GitHelper, try_cached_clone_or_update +from openhands.sdk.git.utils import extract_repo_name, is_git_url, normalize_git_url +from openhands.sdk.logger import get_logger + + +logger = get_logger(__name__) + + +class ExtensionFetchError(Exception): + """Raised when fetching an extension fails.""" + + +def fetch( + source: str, + cache_dir: Path | None = None, + ref: str | None = None, + update: bool = True, + repo_path: str | None = None, + git_helper: GitHelper | None = None, +) -> Path: + """Fetch an extension from a source and return the local path. + + Args: + source: Extension source -- git URL, GitHub shorthand, or local path. + cache_dir: Directory for caching. + ref: Optional branch, tag, or commit to checkout. + update: If true and cache exists, update it. + repo_path: Subdirectory path within the repository. + git_helper: GitHelper instance (for testing). + + Returns: + Path to the local extension directory. + """ + path, _ = fetch_with_resolution( + source=source, + cache_dir=cache_dir, + ref=ref, + update=update, + repo_path=repo_path, + git_helper=git_helper, + ) + return path + + +def fetch_with_resolution( + source: str, + cache_dir: Path | None = None, + ref: str | None = None, + update: bool = True, + repo_path: str | None = None, + git_helper: GitHelper | None = None, +) -> tuple[Path, str | None]: + """Fetch an extension and return both the path and resolved commit SHA. + + Args: + source: Extension source (git URL, GitHub shorthand, or local path). + cache_dir: Directory for caching. + ref: Optional branch, tag, or commit to checkout. + update: If True and cache exists, update it. + repo_path: Subdirectory path within the repository. + git_helper: GitHelper instance (for testing). + + Returns: + Tuple of (path, resolved_ref) where resolved_ref is the commit SHA for git + sources and None for local paths. + + Raises: + ExtensionFetchError: If fetching the extension fails. + """ + raise NotImplementedError() From 4858e59ff16fb9b4ebe5f3b963a711d78577b408 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:27:06 -0600 Subject: [PATCH 02/51] full fetch logic --- .../openhands/sdk/extensions/fetch.py | 203 +++++++++++++++++- 1 file changed, 200 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/fetch.py b/openhands-sdk/openhands/sdk/extensions/fetch.py index 8209cf5f96..22e0716730 100644 --- a/openhands-sdk/openhands/sdk/extensions/fetch.py +++ b/openhands-sdk/openhands/sdk/extensions/fetch.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +from enum import Enum from pathlib import Path from openhands.sdk.git.cached_repo import GitHelper, try_cached_clone_or_update @@ -16,9 +18,114 @@ class ExtensionFetchError(Exception): """Raised when fetching an extension fails.""" +class SourceType(str, Enum): + LOCAL = "local" + GIT = "git" + GITHUB = "github" + + +def parse_extension_source(source: str) -> tuple[SourceType, str]: + """Parse extension source into (SourceType, url). + + Args: + source: Plugin source string. Can be: + - "github:owner/repo" - GitHub repository shorthand + - "https://github.com/owner/repo.git" - Full git URL + - "git@github.com:owner/repo.git" - SSH git URL + - "/local/path" - Local path + + Returns: + Tuple of (source_type, normalized_url) where source_type is one of: + - "github": GitHub repository + - "git": Any git URL + - "local": Local filesystem path + + Examples: + >>> parse_plugin_source("github:owner/repo") + ("github", "https://github.com/owner/repo.git") + >>> parse_plugin_source("https://gitlab.com/org/repo.git") + ("git", "https://gitlab.com/org/repo.git") + >>> parse_plugin_source("/local/path") + ("local", "/local/path") + """ + source = source.strip() + + # GitHub shorthand: github:owner/repo + if source.startswith("github:"): + repo_path = source[7:] # Remove "github:" prefix + # Validate format + if "/" not in repo_path or repo_path.count("/") > 1: + raise ExtensionFetchError( + f"Invalid GitHub shorthand format: {source}. " + f"Expected format: github:owner/repo" + ) + url = f"https://github.com/{repo_path}.git" + return (SourceType.GITHUB, url) + + # Git URLs: detect by protocol/scheme rather than enumerating providers + # This handles GitHub, GitLab, Bitbucket, Codeberg, self-hosted instances, etc. + if is_git_url(source): + url = normalize_git_url(source) + return (SourceType.GIT, url) + + # Local path: starts with /, ~, . or contains / without a URL scheme + if source.startswith(("/", "~", ".")): + return (SourceType.LOCAL, source) + + if "/" in source and "://" not in source: + # Relative path like "plugins/my-plugin" + return (SourceType.LOCAL, source) + + raise ExtensionFetchError( + f"Unable to parse extension source: {source}. " + f"Expected formats: 'github:owner/repo', git URL, or local path" + ) + + +def _resolve_local_source(url: str) -> Path: + """Resolve a local extension source to a path. + + Args: + url: Local path string (may contain ~ for home directory). + + Returns: + Resolved absolute path to the extension directory. + + Raises: + ExtensionFetchError: If path doesn't exist. + """ + local_path = Path(url).expanduser().resolve() + if not local_path.exists(): + raise ExtensionFetchError(f"Local extension path does not exist: {local_path}") + return local_path + + +def _apply_subpath(base_path: Path, subpath: str | None, context: str) -> Path: + """Apply a subpath to a base path, validating it exists. + + Args: + base_path: The root path. + subpath: Optional subdirectory path (may have leading/trailing slashes). + context: Description for error messages (e.g., "plugin repository"). + + Returns: + The final path (base_path if no subpath, otherwise base_path/subpath). + + Raises: + ExtensionFetchError: If subpath doesn't exist. + """ + if not subpath: + return base_path + + final_path = base_path / subpath.strip("/") + if not final_path.exists(): + raise ExtensionFetchError(f"Subdirectory '{subpath}' not found in {context}") + return final_path + + def fetch( source: str, - cache_dir: Path | None = None, + cache_dir: Path, ref: str | None = None, update: bool = True, repo_path: str | None = None, @@ -50,7 +157,7 @@ def fetch( def fetch_with_resolution( source: str, - cache_dir: Path | None = None, + cache_dir: Path, ref: str | None = None, update: bool = True, repo_path: str | None = None, @@ -73,4 +180,94 @@ def fetch_with_resolution( Raises: ExtensionFetchError: If fetching the extension fails. """ - raise NotImplementedError() + source_type, url = parse_extension_source(source) + + if source_type == SourceType.LOCAL: + if repo_path is not None: + raise ExtensionFetchError( + f"repo_path is not supported for local extension sources. " + f"Specify the full path directly instead of " + f"source='{source}' + repo_path='{repo_path}'" + ) + return _resolve_local_source(url), None + + git = git_helper if git_helper is not None else GitHelper() + + plugin_path, resolved_ref = _fetch_remote_source_with_resolution( + url, cache_dir, ref, update, repo_path, git, source + ) + return plugin_path, resolved_ref + + +def get_cache_path(source: str, cache_dir: Path) -> Path: + """Get the cache path for a plugin source. + + Creates a deterministic path based on a hash of the source URL. + + Args: + source: The plugin source (URL or path). + cache_dir: Base cache directory. + + Returns: + Path where the plugin should be cached. + """ + # Create a hash of the source for the directory name + source_hash = hashlib.sha256(source.encode()).hexdigest()[:16] + + # Extract repo name for human-readable cache directory name + readable_name = extract_repo_name(source) + + cache_name = f"{readable_name}-{source_hash}" + return cache_dir / cache_name + + +def _fetch_remote_source_with_resolution( + url: str, + cache_dir: Path, + ref: str | None, + update: bool, + subpath: str | None, + git_helper: GitHelper, + source: str, +) -> tuple[Path, str]: + """Fetch a remote extension source and return path + resolved commit SHA. + + Args: + url: Git URL to fetch. + cache_dir: Base directory for caching. + ref: Optional branch, tag, or commit to checkout. + update: Whether to update existing cache. + subpath: Optional subdirectory within the repository. + git_helper: GitHelper instance for git operations. + source: Original source string (for error messages). + + Returns: + Tuple of (path, resolved_ref) where resolved_ref is the commit SHA. + + Raises: + ExtensionFetchError: If fetching fails or subpath is invalid. + """ + repo_cache_path = get_cache_path(url, cache_dir) + cache_dir.mkdir(parents=True, exist_ok=True) + + result = try_cached_clone_or_update( + url=url, + repo_path=repo_cache_path, + ref=ref, + update=update, + git_helper=git_helper, + ) + + if result is None: + raise ExtensionFetchError(f"Failed to fetch extension from {source}") + + # Get the actual commit SHA that was checked out + try: + resolved_ref = git_helper.get_head_commit(repo_cache_path) + except Exception as e: + logger.warning(f"Could not get commit SHA for {source}: {e}") + # Fall back to the requested ref if we can't get the SHA + resolved_ref = ref or "HEAD" + + final_path = _apply_subpath(repo_cache_path, subpath, "extension repository") + return final_path, resolved_ref From dae2ddc9f02c44069d3f14554bcbd569843a9d33 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:31:36 -0600 Subject: [PATCH 03/51] Port fetch tests to tests/sdk/extensions/test_fetch.py Cover parse_extension_source, get_cache_path, fetch, fetch_with_resolution, SourceType enum, and repo_path handling for the new shared extensions module. Co-authored-by: openhands --- tests/sdk/extensions/__init__.py | 0 tests/sdk/extensions/test_fetch.py | 496 +++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 tests/sdk/extensions/__init__.py create mode 100644 tests/sdk/extensions/test_fetch.py diff --git a/tests/sdk/extensions/__init__.py b/tests/sdk/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/sdk/extensions/test_fetch.py b/tests/sdk/extensions/test_fetch.py new file mode 100644 index 0000000000..dcf2b41994 --- /dev/null +++ b/tests/sdk/extensions/test_fetch.py @@ -0,0 +1,496 @@ +"""Tests for extensions fetch utilities.""" + +from pathlib import Path +from unittest.mock import create_autospec + +import pytest + +from openhands.sdk.extensions.fetch import ( + ExtensionFetchError, + SourceType, + fetch, + fetch_with_resolution, + get_cache_path, + parse_extension_source, +) +from openhands.sdk.git.cached_repo import GitHelper +from openhands.sdk.git.exceptions import GitCommandError + + +# -- parse_extension_source --------------------------------------------------- + + +def test_parse_github_shorthand(): + source_type, url = parse_extension_source("github:owner/repo") + assert source_type == SourceType.GITHUB + assert url == "https://github.com/owner/repo.git" + + +def test_parse_github_shorthand_with_whitespace(): + source_type, url = parse_extension_source(" github:owner/repo ") + assert source_type == SourceType.GITHUB + assert url == "https://github.com/owner/repo.git" + + +def test_parse_github_shorthand_invalid_format(): + with pytest.raises(ExtensionFetchError, match="Invalid GitHub shorthand"): + parse_extension_source("github:invalid") + + with pytest.raises(ExtensionFetchError, match="Invalid GitHub shorthand"): + parse_extension_source("github:too/many/parts") + + +def test_parse_https_git_url(): + source_type, url = parse_extension_source("https://github.com/owner/repo.git") + assert source_type == SourceType.GIT + assert url == "https://github.com/owner/repo.git" + + +def test_parse_https_github_url_without_git_suffix(): + source_type, url = parse_extension_source("https://github.com/owner/repo") + assert source_type == SourceType.GIT + assert url == "https://github.com/owner/repo.git" + + +def test_parse_https_github_url_with_trailing_slash(): + source_type, url = parse_extension_source("https://github.com/owner/repo/") + assert source_type == SourceType.GIT + assert url == "https://github.com/owner/repo.git" + + +def test_parse_https_gitlab_url(): + source_type, url = parse_extension_source("https://gitlab.com/org/repo") + assert source_type == SourceType.GIT + assert url == "https://gitlab.com/org/repo.git" + + +def test_parse_https_bitbucket_url(): + source_type, url = parse_extension_source("https://bitbucket.org/org/repo") + assert source_type == SourceType.GIT + assert url == "https://bitbucket.org/org/repo.git" + + +def test_parse_ssh_git_url(): + source_type, url = parse_extension_source("git@github.com:owner/repo.git") + assert source_type == SourceType.GIT + assert url == "git@github.com:owner/repo.git" + + +def test_parse_git_protocol_url(): + source_type, url = parse_extension_source("git://github.com/owner/repo.git") + assert source_type == SourceType.GIT + assert url == "git://github.com/owner/repo.git" + + +def test_parse_absolute_local_path(): + source_type, url = parse_extension_source("/path/to/extension") + assert source_type == SourceType.LOCAL + assert url == "/path/to/extension" + + +def test_parse_home_relative_path(): + source_type, url = parse_extension_source("~/extensions/my-ext") + assert source_type == SourceType.LOCAL + assert url == "~/extensions/my-ext" + + +def test_parse_dot_relative_path(): + source_type, url = parse_extension_source("./extensions/my-ext") + assert source_type == SourceType.LOCAL + assert url == "./extensions/my-ext" + + +def test_parse_invalid_source(): + with pytest.raises(ExtensionFetchError, match="Unable to parse extension source"): + parse_extension_source("invalid-source-format") + + +def test_parse_self_hosted_git_urls(): + source_type, url = parse_extension_source("https://codeberg.org/user/repo") + assert source_type == SourceType.GIT + assert url == "https://codeberg.org/user/repo.git" + + source_type, url = parse_extension_source("https://git.mycompany.com/org/repo") + assert source_type == SourceType.GIT + assert url == "https://git.mycompany.com/org/repo.git" + + +def test_parse_http_url(): + source_type, url = parse_extension_source("http://internal-git.local/repo") + assert source_type == SourceType.GIT + assert url == "http://internal-git.local/repo.git" + + +def test_parse_ssh_with_custom_user(): + ssh_url = "deploy@git.example.com:project/repo.git" + source_type, url = parse_extension_source(ssh_url) + assert source_type == SourceType.GIT + assert url == ssh_url + + +def test_parse_relative_path_with_slash(): + source_type, url = parse_extension_source("extensions/my-ext") + assert source_type == SourceType.LOCAL + assert url == "extensions/my-ext" + + +def test_parse_nested_relative_path(): + source_type, url = parse_extension_source("path/to/my/extension") + assert source_type == SourceType.LOCAL + assert url == "path/to/my/extension" + + +# -- SourceType enum ---------------------------------------------------------- + + +def test_source_type_values(): + assert SourceType.LOCAL == "local" + assert SourceType.GIT == "git" + assert SourceType.GITHUB == "github" + + +# -- get_cache_path ------------------------------------------------------------ + + +def test_cache_path_deterministic(tmp_path: Path): + source = "https://github.com/owner/repo.git" + path1 = get_cache_path(source, tmp_path) + path2 = get_cache_path(source, tmp_path) + assert path1 == path2 + + +def test_cache_path_different_sources(tmp_path: Path): + path1 = get_cache_path("https://github.com/owner/repo1.git", tmp_path) + path2 = get_cache_path("https://github.com/owner/repo2.git", tmp_path) + assert path1 != path2 + + +def test_cache_path_includes_readable_name(tmp_path: Path): + source = "https://github.com/owner/my-extension.git" + path = get_cache_path(source, tmp_path) + assert "my-extension" in path.name + + +# -- fetch (local sources) ---------------------------------------------------- + + +def test_fetch_local_path(tmp_path: Path): + ext_dir = tmp_path / "my-ext" + ext_dir.mkdir() + + result = fetch(str(ext_dir), cache_dir=tmp_path) + assert result == ext_dir.resolve() + + +def test_fetch_local_path_nonexistent(tmp_path: Path): + with pytest.raises(ExtensionFetchError, match="does not exist"): + fetch(str(tmp_path / "nonexistent"), cache_dir=tmp_path) + + +# -- fetch (remote sources) --------------------------------------------------- + + +def test_fetch_github_shorthand_clones(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + + result = fetch( + "github:owner/repo", + cache_dir=tmp_path, + git_helper=mock_git, + ) + + assert result.exists() + mock_git.clone.assert_called_once() + call_args = mock_git.clone.call_args + assert call_args[0][0] == "https://github.com/owner/repo.git" + + +def test_fetch_with_ref(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + + fetch( + "github:owner/repo", + cache_dir=tmp_path, + ref="v1.0.0", + git_helper=mock_git, + ) + + mock_git.clone.assert_called_once() + call_kwargs = mock_git.clone.call_args[1] + assert call_kwargs["branch"] == "v1.0.0" + + +def test_fetch_updates_existing_cache(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + mock_git.get_current_branch.return_value = "main" + + cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) + cache_path.mkdir(parents=True) + (cache_path / ".git").mkdir() + + result = fetch( + "github:owner/repo", + cache_dir=tmp_path, + update=True, + git_helper=mock_git, + ) + + assert result == cache_path + mock_git.fetch.assert_called() + mock_git.clone.assert_not_called() + + +def test_fetch_no_update_uses_cache(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) + cache_path.mkdir(parents=True) + (cache_path / ".git").mkdir() + + result = fetch( + "github:owner/repo", + cache_dir=tmp_path, + update=False, + git_helper=mock_git, + ) + + assert result == cache_path + mock_git.clone.assert_not_called() + mock_git.fetch.assert_not_called() + + +def test_fetch_no_update_with_ref_checks_out(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) + cache_path.mkdir(parents=True) + (cache_path / ".git").mkdir() + + fetch( + "github:owner/repo", + cache_dir=tmp_path, + update=False, + ref="v1.0.0", + git_helper=mock_git, + ) + + mock_git.checkout.assert_called_once_with(cache_path, "v1.0.0") + + +def test_fetch_git_error_raises_extension_fetch_error(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + mock_git.clone.side_effect = GitCommandError( + "fatal: repository not found", + command=["git", "clone"], + exit_code=128, + ) + + with pytest.raises(ExtensionFetchError, match="Failed to fetch extension"): + fetch( + "github:owner/nonexistent", + cache_dir=tmp_path, + git_helper=mock_git, + ) + + +def test_fetch_generic_error_raises_extension_fetch_error(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + mock_git.clone.side_effect = RuntimeError("Unexpected error") + + with pytest.raises(ExtensionFetchError, match="Failed to fetch extension"): + fetch( + "github:owner/repo", + cache_dir=tmp_path, + git_helper=mock_git, + ) + + +# -- fetch_with_resolution ---------------------------------------------------- + + +def test_fetch_with_resolution_local_returns_none_ref(tmp_path: Path): + ext_dir = tmp_path / "my-ext" + ext_dir.mkdir() + + path, resolved_ref = fetch_with_resolution(str(ext_dir), cache_dir=tmp_path) + assert path == ext_dir.resolve() + assert resolved_ref is None + + +def test_fetch_with_resolution_remote_returns_sha(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + mock_git.get_head_commit.return_value = "abc123deadbeef" + + path, resolved_ref = fetch_with_resolution( + "github:owner/repo", + cache_dir=tmp_path, + git_helper=mock_git, + ) + + assert path.exists() + assert resolved_ref == "abc123deadbeef" + + +def test_fetch_with_resolution_falls_back_on_sha_error(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + mock_git.get_head_commit.side_effect = RuntimeError("not a git repo") + + path, resolved_ref = fetch_with_resolution( + "github:owner/repo", + cache_dir=tmp_path, + ref="v2.0", + git_helper=mock_git, + ) + + assert path.exists() + assert resolved_ref == "v2.0" + + +def test_fetch_with_resolution_falls_back_to_head(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + mock_git.get_head_commit.side_effect = RuntimeError("not a git repo") + + path, resolved_ref = fetch_with_resolution( + "github:owner/repo", + cache_dir=tmp_path, + git_helper=mock_git, + ) + + assert path.exists() + assert resolved_ref == "HEAD" + + +# -- repo_path parameter ------------------------------------------------------ + + +def test_fetch_local_with_repo_path_raises_error(tmp_path: Path): + ext_dir = tmp_path / "monorepo" + ext_dir.mkdir() + (ext_dir / "extensions" / "my-ext").mkdir(parents=True) + + with pytest.raises( + ExtensionFetchError, + match="repo_path is not supported for local", + ): + fetch( + str(ext_dir), + cache_dir=tmp_path, + repo_path="extensions/my-ext", + ) + + +def test_fetch_github_with_repo_path(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + subdir = dest / "extensions" / "sub-ext" + subdir.mkdir(parents=True) + + mock_git.clone.side_effect = clone_side_effect + + result = fetch( + "github:owner/monorepo", + cache_dir=tmp_path, + repo_path="extensions/sub-ext", + git_helper=mock_git, + ) + + assert result.exists() + assert result.name == "sub-ext" + assert "extensions" in str(result) + + +def test_fetch_github_with_nonexistent_repo_path(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_git.clone.side_effect = clone_side_effect + + with pytest.raises(ExtensionFetchError, match="Subdirectory.*not found"): + fetch( + "github:owner/repo", + cache_dir=tmp_path, + repo_path="nonexistent", + git_helper=mock_git, + ) + + +def test_fetch_with_repo_path_and_ref(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + subdir = dest / "extensions" / "my-ext" + subdir.mkdir(parents=True) + + mock_git.clone.side_effect = clone_side_effect + + result = fetch( + "github:owner/monorepo", + cache_dir=tmp_path, + ref="v1.0.0", + repo_path="extensions/my-ext", + git_helper=mock_git, + ) + + assert result.exists() + mock_git.clone.assert_called_once() + call_kwargs = mock_git.clone.call_args[1] + assert call_kwargs["branch"] == "v1.0.0" + + +def test_fetch_no_repo_path_returns_root(tmp_path: Path): + mock_git = create_autospec(GitHelper, instance=True) + + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + (dest / "extensions").mkdir() + + mock_git.clone.side_effect = clone_side_effect + + result = fetch( + "github:owner/repo", + cache_dir=tmp_path, + repo_path=None, + git_helper=mock_git, + ) + + assert result.exists() + assert (result / ".git").exists() From 5187d874af5d28cf0e12bb34665860ea9a35d657 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:32:44 -0600 Subject: [PATCH 04/51] minor --- openhands-sdk/openhands/sdk/extensions/fetch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/fetch.py b/openhands-sdk/openhands/sdk/extensions/fetch.py index 22e0716730..8efb272c8e 100644 --- a/openhands-sdk/openhands/sdk/extensions/fetch.py +++ b/openhands-sdk/openhands/sdk/extensions/fetch.py @@ -200,7 +200,7 @@ def fetch_with_resolution( def get_cache_path(source: str, cache_dir: Path) -> Path: - """Get the cache path for a plugin source. + """Get the cache path for an extension source. Creates a deterministic path based on a hash of the source URL. From d1b3b6b5ab7216ea7f5bd0de661eda8ce193ff42 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:35:50 -0600 Subject: [PATCH 05/51] Add SourceType docstring and fix stale plugin refs in extensions/fetch.py - Add class docstring to SourceType enum describing each variant. - Update parse_extension_source docstring: fix arg description, return types, and example function names. - Fix _apply_subpath docstring example from 'plugin repository' to 'extension repository'. - Fix get_cache_path docstring references from plugin to extension. - Rename plugin_path variable to ext_path in fetch_with_resolution. Co-authored-by: openhands --- .../openhands/sdk/extensions/fetch.py | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/fetch.py b/openhands-sdk/openhands/sdk/extensions/fetch.py index 8efb272c8e..c885eb2751 100644 --- a/openhands-sdk/openhands/sdk/extensions/fetch.py +++ b/openhands-sdk/openhands/sdk/extensions/fetch.py @@ -19,6 +19,13 @@ class ExtensionFetchError(Exception): class SourceType(str, Enum): + """Classification of an extension source. + + LOCAL -- a filesystem path (absolute, home-relative, or dot-relative). + GIT -- any git-clonable URL (HTTPS, SSH, git://, etc.). + GITHUB -- the ``github:owner/repo`` shorthand, expanded to an HTTPS URL. + """ + LOCAL = "local" GIT = "git" GITHUB = "github" @@ -28,7 +35,7 @@ def parse_extension_source(source: str) -> tuple[SourceType, str]: """Parse extension source into (SourceType, url). Args: - source: Plugin source string. Can be: + source: Extension source string. Can be: - "github:owner/repo" - GitHub repository shorthand - "https://github.com/owner/repo.git" - Full git URL - "git@github.com:owner/repo.git" - SSH git URL @@ -36,17 +43,17 @@ def parse_extension_source(source: str) -> tuple[SourceType, str]: Returns: Tuple of (source_type, normalized_url) where source_type is one of: - - "github": GitHub repository - - "git": Any git URL - - "local": Local filesystem path + - SourceType.GITHUB: GitHub repository + - SourceType.GIT: Any git URL + - SourceType.LOCAL: Local filesystem path Examples: - >>> parse_plugin_source("github:owner/repo") - ("github", "https://github.com/owner/repo.git") - >>> parse_plugin_source("https://gitlab.com/org/repo.git") - ("git", "https://gitlab.com/org/repo.git") - >>> parse_plugin_source("/local/path") - ("local", "/local/path") + >>> parse_extension_source("github:owner/repo") + (SourceType.GITHUB, "https://github.com/owner/repo.git") + >>> parse_extension_source("https://gitlab.com/org/repo.git") + (SourceType.GIT, "https://gitlab.com/org/repo.git") + >>> parse_extension_source("/local/path") + (SourceType.LOCAL, "/local/path") """ source = source.strip() @@ -106,7 +113,7 @@ def _apply_subpath(base_path: Path, subpath: str | None, context: str) -> Path: Args: base_path: The root path. subpath: Optional subdirectory path (may have leading/trailing slashes). - context: Description for error messages (e.g., "plugin repository"). + context: Description for error messages (e.g., "extension repository"). Returns: The final path (base_path if no subpath, otherwise base_path/subpath). @@ -193,10 +200,10 @@ def fetch_with_resolution( git = git_helper if git_helper is not None else GitHelper() - plugin_path, resolved_ref = _fetch_remote_source_with_resolution( + ext_path, resolved_ref = _fetch_remote_source_with_resolution( url, cache_dir, ref, update, repo_path, git, source ) - return plugin_path, resolved_ref + return ext_path, resolved_ref def get_cache_path(source: str, cache_dir: Path) -> Path: @@ -205,11 +212,11 @@ def get_cache_path(source: str, cache_dir: Path) -> Path: Creates a deterministic path based on a hash of the source URL. Args: - source: The plugin source (URL or path). + source: The extension source (URL or path). cache_dir: Base cache directory. Returns: - Path where the plugin should be cached. + Path where the extension should be cached. """ # Create a hash of the source for the directory name source_hash = hashlib.sha256(source.encode()).hexdigest()[:16] From e0990614f42adf389efcccde02edd5335ec7c535 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:42:28 -0600 Subject: [PATCH 06/51] Refactor plugin and skills fetch to delegate to extensions.fetch Both modules now delegate all fetch logic to the shared extensions.fetch module while preserving their public interfaces: - plugin/fetch.py: parse_plugin_source, get_cache_path, fetch_plugin, fetch_plugin_with_resolution, PluginFetchError, DEFAULT_CACHE_DIR - skills/fetch.py: fetch_skill, fetch_skill_with_resolution, SkillFetchError, DEFAULT_CACHE_DIR ExtensionFetchError is caught and re-raised as the domain-specific error type with 'extension' replaced by 'plugin'/'skill' in messages. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/plugin/fetch.py | 237 +++----------------- openhands-sdk/openhands/sdk/skills/fetch.py | 16 +- 2 files changed, 43 insertions(+), 210 deletions(-) diff --git a/openhands-sdk/openhands/sdk/plugin/fetch.py b/openhands-sdk/openhands/sdk/plugin/fetch.py index 592236dfd7..0e78ca85af 100644 --- a/openhands-sdk/openhands/sdk/plugin/fetch.py +++ b/openhands-sdk/openhands/sdk/plugin/fetch.py @@ -1,16 +1,22 @@ -"""Plugin fetching utilities for remote plugin sources.""" +"""Plugin fetching utilities for remote plugin sources. + +Delegates to :mod:`openhands.sdk.extensions.fetch` for the actual fetch logic +and re-raises errors as :class:`PluginFetchError` to preserve the existing +public interface. +""" from __future__ import annotations -import hashlib from pathlib import Path -from openhands.sdk.git.cached_repo import GitHelper, try_cached_clone_or_update -from openhands.sdk.git.utils import extract_repo_name, is_git_url, normalize_git_url -from openhands.sdk.logger import get_logger - +from openhands.sdk.extensions.fetch import ( + ExtensionFetchError, + fetch_with_resolution as _ext_fetch_with_resolution, + get_cache_path as _ext_get_cache_path, + parse_extension_source, +) +from openhands.sdk.git.cached_repo import GitHelper -logger = get_logger(__name__) DEFAULT_CACHE_DIR = Path.home() / ".openhands" / "cache" / "plugins" @@ -18,8 +24,6 @@ class PluginFetchError(Exception): """Raised when fetching a plugin fails.""" - pass - def parse_plugin_source(source: str) -> tuple[str, str]: """Parse plugin source into (type, url). @@ -45,38 +49,11 @@ def parse_plugin_source(source: str) -> tuple[str, str]: >>> parse_plugin_source("/local/path") ("local", "/local/path") """ - source = source.strip() - - # GitHub shorthand: github:owner/repo - if source.startswith("github:"): - repo_path = source[7:] # Remove "github:" prefix - # Validate format - if "/" not in repo_path or repo_path.count("/") > 1: - raise PluginFetchError( - f"Invalid GitHub shorthand format: {source}. " - f"Expected format: github:owner/repo" - ) - url = f"https://github.com/{repo_path}.git" - return ("github", url) - - # Git URLs: detect by protocol/scheme rather than enumerating providers - # This handles GitHub, GitLab, Bitbucket, Codeberg, self-hosted instances, etc. - if is_git_url(source): - url = normalize_git_url(source) - return ("git", url) - - # Local path: starts with /, ~, . or contains / without a URL scheme - if source.startswith(("/", "~", ".")): - return ("local", source) - - if "/" in source and "://" not in source: - # Relative path like "plugins/my-plugin" - return ("local", source) - - raise PluginFetchError( - f"Unable to parse plugin source: {source}. " - f"Expected formats: 'github:owner/repo', git URL, or local path" - ) + try: + source_type, url = parse_extension_source(source) + except ExtensionFetchError as exc: + raise PluginFetchError(str(exc).replace("extension", "plugin")) from exc + return (source_type.value, url) def get_cache_path(source: str, cache_dir: Path | None = None) -> Path: @@ -91,102 +68,10 @@ def get_cache_path(source: str, cache_dir: Path | None = None) -> Path: Returns: Path where the plugin should be cached. """ - if cache_dir is None: - cache_dir = DEFAULT_CACHE_DIR - - # Create a hash of the source for the directory name - source_hash = hashlib.sha256(source.encode()).hexdigest()[:16] - - # Extract repo name for human-readable cache directory name - readable_name = extract_repo_name(source) - - cache_name = f"{readable_name}-{source_hash}" - return cache_dir / cache_name - - -def _resolve_local_source(url: str) -> Path: - """Resolve a local plugin source to a path. - - Args: - url: Local path string (may contain ~ for home directory). - - Returns: - Resolved absolute path to the plugin directory. - - Raises: - PluginFetchError: If path doesn't exist. - """ - local_path = Path(url).expanduser().resolve() - if not local_path.exists(): - raise PluginFetchError(f"Local plugin path does not exist: {local_path}") - return local_path - - -def _fetch_remote_source( - url: str, - cache_dir: Path, - ref: str | None, - update: bool, - subpath: str | None, - git_helper: GitHelper | None, - source: str, -) -> Path: - """Fetch a remote plugin source and cache it locally. - - Args: - url: Git URL to fetch. - cache_dir: Base directory for caching. - ref: Optional branch, tag, or commit to checkout. - update: Whether to update existing cache. - subpath: Optional subdirectory within the repository. - git_helper: GitHelper instance for git operations. - source: Original source string (for error messages). - - Returns: - Path to the cached plugin directory. - - Raises: - PluginFetchError: If fetching fails or subpath is invalid. - """ - plugin_path = get_cache_path(url, cache_dir) - cache_dir.mkdir(parents=True, exist_ok=True) - - result = try_cached_clone_or_update( - url=url, - repo_path=plugin_path, - ref=ref, - update=update, - git_helper=git_helper, + return _ext_get_cache_path( + source, cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR ) - if result is None: - raise PluginFetchError(f"Failed to fetch plugin from {source}") - - return _apply_subpath(plugin_path, subpath, "plugin repository") - - -def _apply_subpath(base_path: Path, subpath: str | None, context: str) -> Path: - """Apply a subpath to a base path, validating it exists. - - Args: - base_path: The root path. - subpath: Optional subdirectory path (may have leading/trailing slashes). - context: Description for error messages (e.g., "plugin repository"). - - Returns: - The final path (base_path if no subpath, otherwise base_path/subpath). - - Raises: - PluginFetchError: If subpath doesn't exist. - """ - if not subpath: - return base_path - - final_path = base_path / subpath.strip("/") - if not final_path.exists(): - raise PluginFetchError(f"Subdirectory '{subpath}' not found in {context}") - return final_path - def fetch_plugin( source: str, @@ -261,75 +146,15 @@ def fetch_plugin_with_resolution( Raises: PluginFetchError: If fetching fails or repo_path doesn't exist. """ - source_type, url = parse_plugin_source(source) - - if source_type == "local": - if repo_path is not None: - raise PluginFetchError( - f"repo_path is not supported for local plugin sources. " - f"Specify the full path directly instead of " - f"source='{source}' + repo_path='{repo_path}'" - ) - return _resolve_local_source(url), None - - if cache_dir is None: - cache_dir = DEFAULT_CACHE_DIR - - git = git_helper if git_helper is not None else GitHelper() - - plugin_path, resolved_ref = _fetch_remote_source_with_resolution( - url, cache_dir, ref, update, repo_path, git, source - ) - return plugin_path, resolved_ref - - -def _fetch_remote_source_with_resolution( - url: str, - cache_dir: Path, - ref: str | None, - update: bool, - subpath: str | None, - git_helper: GitHelper, - source: str, -) -> tuple[Path, str]: - """Fetch a remote plugin source and return path + resolved commit SHA. - - Args: - url: Git URL to fetch. - cache_dir: Base directory for caching. - ref: Optional branch, tag, or commit to checkout. - update: Whether to update existing cache. - subpath: Optional subdirectory within the repository. - git_helper: GitHelper instance for git operations. - source: Original source string (for error messages). - - Returns: - Tuple of (path, resolved_ref) where resolved_ref is the commit SHA. - - Raises: - PluginFetchError: If fetching fails or subpath is invalid. - """ - repo_cache_path = get_cache_path(url, cache_dir) - cache_dir.mkdir(parents=True, exist_ok=True) - - result = try_cached_clone_or_update( - url=url, - repo_path=repo_cache_path, - ref=ref, - update=update, - git_helper=git_helper, - ) - - if result is None: - raise PluginFetchError(f"Failed to fetch plugin from {source}") - - # Get the actual commit SHA that was checked out + resolved_cache_dir = cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR try: - resolved_ref = git_helper.get_head_commit(repo_cache_path) - except Exception as e: - logger.warning(f"Could not get commit SHA for {source}: {e}") - # Fall back to the requested ref if we can't get the SHA - resolved_ref = ref or "HEAD" - - final_path = _apply_subpath(repo_cache_path, subpath, "plugin repository") - return final_path, resolved_ref + return _ext_fetch_with_resolution( + source=source, + cache_dir=resolved_cache_dir, + ref=ref, + update=update, + repo_path=repo_path, + git_helper=git_helper, + ) + except ExtensionFetchError as exc: + raise PluginFetchError(str(exc).replace("extension", "plugin")) from exc diff --git a/openhands-sdk/openhands/sdk/skills/fetch.py b/openhands-sdk/openhands/sdk/skills/fetch.py index fea5a99f50..57618d0e15 100644 --- a/openhands-sdk/openhands/sdk/skills/fetch.py +++ b/openhands-sdk/openhands/sdk/skills/fetch.py @@ -1,11 +1,19 @@ -"""Skill fetching utilities for AgentSkills sources.""" +"""Skill fetching utilities for AgentSkills sources. + +Delegates to :mod:`openhands.sdk.extensions.fetch` for the actual fetch logic +and re-raises errors as :class:`SkillFetchError` to preserve the existing +public interface. +""" from __future__ import annotations from pathlib import Path +from openhands.sdk.extensions.fetch import ( + ExtensionFetchError, + fetch_with_resolution as _ext_fetch_with_resolution, +) from openhands.sdk.git.cached_repo import GitHelper -from openhands.sdk.plugin.fetch import PluginFetchError, fetch_plugin_with_resolution DEFAULT_CACHE_DIR = Path.home() / ".openhands" / "cache" / "skills" @@ -74,7 +82,7 @@ def fetch_skill_with_resolution( """ resolved_cache_dir = cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR try: - return fetch_plugin_with_resolution( + return _ext_fetch_with_resolution( source=source, cache_dir=resolved_cache_dir, ref=ref, @@ -82,5 +90,5 @@ def fetch_skill_with_resolution( repo_path=repo_path, git_helper=git_helper, ) - except PluginFetchError as exc: + except ExtensionFetchError as exc: raise SkillFetchError(str(exc)) from exc From b3e72968707989bc22d8852af48982b8997012c1 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 10:54:38 -0600 Subject: [PATCH 07/51] Remove parse_plugin_source/get_cache_path, slim plugin tests - Remove parse_plugin_source and get_cache_path from plugin/fetch.py; callers should use extensions.fetch directly. - Slim test_plugin_fetch.py from 1038 to 100 lines: keep only PluginFetchError wrapping, DEFAULT_CACHE_DIR, and Plugin.fetch() tests. - Move git infrastructure tests (clone, update, checkout, locking, GitHelper errors, get_default_branch) to tests/sdk/git/test_cached_repo.py. - Update test_installed_plugins.py to import from extensions.fetch. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/plugin/fetch.py | 50 - tests/sdk/git/test_cached_repo.py | 472 +++++++++ tests/sdk/plugin/test_installed_plugins.py | 7 +- tests/sdk/plugin/test_plugin_fetch.py | 1060 ++----------------- 4 files changed, 537 insertions(+), 1052 deletions(-) create mode 100644 tests/sdk/git/test_cached_repo.py diff --git a/openhands-sdk/openhands/sdk/plugin/fetch.py b/openhands-sdk/openhands/sdk/plugin/fetch.py index 0e78ca85af..2fcea18577 100644 --- a/openhands-sdk/openhands/sdk/plugin/fetch.py +++ b/openhands-sdk/openhands/sdk/plugin/fetch.py @@ -12,8 +12,6 @@ from openhands.sdk.extensions.fetch import ( ExtensionFetchError, fetch_with_resolution as _ext_fetch_with_resolution, - get_cache_path as _ext_get_cache_path, - parse_extension_source, ) from openhands.sdk.git.cached_repo import GitHelper @@ -25,54 +23,6 @@ class PluginFetchError(Exception): """Raised when fetching a plugin fails.""" -def parse_plugin_source(source: str) -> tuple[str, str]: - """Parse plugin source into (type, url). - - Args: - source: Plugin source string. Can be: - - "github:owner/repo" - GitHub repository shorthand - - "https://github.com/owner/repo.git" - Full git URL - - "git@github.com:owner/repo.git" - SSH git URL - - "/local/path" - Local path - - Returns: - Tuple of (source_type, normalized_url) where source_type is one of: - - "github": GitHub repository - - "git": Any git URL - - "local": Local filesystem path - - Examples: - >>> parse_plugin_source("github:owner/repo") - ("github", "https://github.com/owner/repo.git") - >>> parse_plugin_source("https://gitlab.com/org/repo.git") - ("git", "https://gitlab.com/org/repo.git") - >>> parse_plugin_source("/local/path") - ("local", "/local/path") - """ - try: - source_type, url = parse_extension_source(source) - except ExtensionFetchError as exc: - raise PluginFetchError(str(exc).replace("extension", "plugin")) from exc - return (source_type.value, url) - - -def get_cache_path(source: str, cache_dir: Path | None = None) -> Path: - """Get the cache path for a plugin source. - - Creates a deterministic path based on a hash of the source URL. - - Args: - source: The plugin source (URL or path). - cache_dir: Base cache directory. Defaults to ~/.openhands/cache/plugins/ - - Returns: - Path where the plugin should be cached. - """ - return _ext_get_cache_path( - source, cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR - ) - - def fetch_plugin( source: str, cache_dir: Path | None = None, diff --git a/tests/sdk/git/test_cached_repo.py b/tests/sdk/git/test_cached_repo.py new file mode 100644 index 0000000000..b9e9e2b91e --- /dev/null +++ b/tests/sdk/git/test_cached_repo.py @@ -0,0 +1,472 @@ +"""Tests for git cached_repo helpers (clone, update, checkout, locking).""" + +import subprocess +from pathlib import Path +from unittest.mock import create_autospec, patch + +import pytest + +from openhands.sdk.git.cached_repo import ( + GitHelper, + _checkout_ref, + _clone_repository, + _update_repository, +) +from openhands.sdk.git.exceptions import GitCommandError + + +# -- _clone_repository --------------------------------------------------------- + + +def test_clone_calls_git_helper(tmp_path: Path): + mock_git = create_autospec(GitHelper) + dest = tmp_path / "repo" + + _clone_repository("https://github.com/owner/repo.git", dest, None, mock_git) + + mock_git.clone.assert_called_once_with( + "https://github.com/owner/repo.git", dest, depth=1, branch=None + ) + + +def test_clone_with_ref(tmp_path: Path): + mock_git = create_autospec(GitHelper) + dest = tmp_path / "repo" + + _clone_repository("https://github.com/owner/repo.git", dest, "v1.0.0", mock_git) + + mock_git.clone.assert_called_once_with( + "https://github.com/owner/repo.git", dest, depth=1, branch="v1.0.0" + ) + + +def test_clone_removes_existing_directory(tmp_path: Path): + mock_git = create_autospec(GitHelper) + dest = tmp_path / "repo" + dest.mkdir() + (dest / "some_file.txt").write_text("test") + + _clone_repository("https://github.com/owner/repo.git", dest, None, mock_git) + + mock_git.clone.assert_called_once() + + +# -- _update_repository -------------------------------------------------------- + + +def test_update_fetches_and_resets(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = "main" + + _update_repository(tmp_path, None, mock_git) + + mock_git.fetch.assert_called_once_with(tmp_path) + mock_git.get_current_branch.assert_called_once_with(tmp_path) + mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") + + +def test_update_with_ref_checks_out(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = None + + _update_repository(tmp_path, "v1.0.0", mock_git) + + mock_git.fetch.assert_called_once_with(tmp_path) + mock_git.checkout.assert_called_once_with(tmp_path, "v1.0.0") + + +def test_update_detached_head_recovers_to_default_branch(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = None + mock_git.get_default_branch.return_value = "main" + + _update_repository(tmp_path, None, mock_git) + + mock_git.fetch.assert_called_once() + mock_git.get_current_branch.assert_called_once() + mock_git.get_default_branch.assert_called_once_with(tmp_path) + mock_git.checkout.assert_called_once_with(tmp_path, "main") + mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") + + +def test_update_detached_head_no_default_branch_logs_warning(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = None + mock_git.get_default_branch.return_value = None + + _update_repository(tmp_path, None, mock_git) + + mock_git.fetch.assert_called_once() + mock_git.get_default_branch.assert_called_once() + mock_git.checkout.assert_not_called() + mock_git.reset_hard.assert_not_called() + + +def test_update_continues_on_fetch_error(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.fetch.side_effect = GitCommandError( + "Network error", command=["git", "fetch"], exit_code=1 + ) + + _update_repository(tmp_path, None, mock_git) + + mock_git.fetch.assert_called_once() + mock_git.get_current_branch.assert_not_called() + + +def test_update_continues_on_checkout_error(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.checkout.side_effect = GitCommandError( + "Invalid ref", command=["git", "checkout"], exit_code=1 + ) + + _update_repository(tmp_path, "nonexistent", mock_git) + + +# -- _checkout_ref ------------------------------------------------------------- + + +def test_checkout_branch_resets_to_origin(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = "main" + + _checkout_ref(tmp_path, "main", mock_git) + + mock_git.checkout.assert_called_once_with(tmp_path, "main") + mock_git.get_current_branch.assert_called_once_with(tmp_path) + mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") + + +def test_checkout_tag_skips_reset(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = None + + _checkout_ref(tmp_path, "v1.0.0", mock_git) + + mock_git.checkout.assert_called_once_with(tmp_path, "v1.0.0") + mock_git.reset_hard.assert_not_called() + + +def test_checkout_commit_skips_reset(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = None + + _checkout_ref(tmp_path, "abc123", mock_git) + + mock_git.checkout.assert_called_once_with(tmp_path, "abc123") + mock_git.reset_hard.assert_not_called() + + +def test_checkout_branch_handles_reset_error(tmp_path: Path): + mock_git = create_autospec(GitHelper) + mock_git.get_current_branch.return_value = "main" + mock_git.reset_hard.side_effect = GitCommandError( + "Reset failed", command=["git", "reset"], exit_code=1 + ) + + _checkout_ref(tmp_path, "main", mock_git) + + mock_git.checkout.assert_called_once() + mock_git.reset_hard.assert_called_once() + + +# -- GitHelper error handling -------------------------------------------------- + + +def test_git_clone_called_process_error(tmp_path: Path): + git = GitHelper() + dest = tmp_path / "repo" + + with pytest.raises(GitCommandError, match="git clone"): + git.clone("https://invalid.example.com/nonexistent.git", dest, timeout=5) + + +def test_git_clone_timeout(tmp_path: Path): + git = GitHelper() + dest = tmp_path / "repo" + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) + with pytest.raises(GitCommandError, match="timed out"): + git.clone("https://github.com/owner/repo.git", dest, timeout=1) + + +def test_git_fetch_with_ref_no_remote(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, + check=True, + ) + subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True) + (repo / "file.txt").write_text("content") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo, check=True) + + git = GitHelper() + with pytest.raises(GitCommandError, match="git fetch"): + git.fetch(repo, ref="main") + + +def test_git_fetch_called_process_error(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "not-a-repo" + repo.mkdir() + + with pytest.raises(GitCommandError, match="git fetch"): + git.fetch(repo) + + +def test_git_fetch_timeout(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) + with pytest.raises(GitCommandError, match="timed out"): + git.fetch(repo, timeout=1) + + +def test_git_checkout_called_process_error(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True) + + with pytest.raises(GitCommandError, match="git checkout"): + git.checkout(repo, "nonexistent-ref") + + +def test_git_checkout_timeout(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) + with pytest.raises(GitCommandError, match="timed out"): + git.checkout(repo, "main", timeout=1) + + +def test_git_reset_hard_called_process_error(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True) + + with pytest.raises(GitCommandError, match="git reset"): + git.reset_hard(repo, "nonexistent-ref") + + +def test_git_reset_hard_timeout(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) + with pytest.raises(GitCommandError, match="timed out"): + git.reset_hard(repo, "HEAD", timeout=1) + + +def test_git_get_current_branch_error(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "not-a-repo" + repo.mkdir() + + with pytest.raises(GitCommandError, match="git rev-parse"): + git.get_current_branch(repo) + + +def test_git_get_current_branch_timeout(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) + with pytest.raises(GitCommandError, match="timed out"): + git.get_current_branch(repo, timeout=1) + + +# -- GitHelper.get_default_branch --------------------------------------------- + + +def test_get_default_branch_returns_main(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout="refs/remotes/origin/main\n", + stderr="", + ) + result = git.get_default_branch(repo) + + assert result == "main" + call_args = mock_run.call_args[0][0] + assert call_args == ["git", "symbolic-ref", "refs/remotes/origin/HEAD"] + + +def test_get_default_branch_returns_master(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout="refs/remotes/origin/master\n", + stderr="", + ) + result = git.get_default_branch(repo) + + assert result == "master" + + +def test_get_default_branch_returns_none_when_not_set(tmp_path: Path): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["git"], + returncode=1, + stdout="", + stderr=("fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"), + ) + result = git.get_default_branch(repo) + + assert result is None + + +def test_get_default_branch_returns_none_on_unexpected_format( + tmp_path: Path, +): + git = GitHelper() + repo = tmp_path / "repo" + repo.mkdir() + + with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout="unexpected-format\n", + stderr="", + ) + result = git.get_default_branch(repo) + + assert result is None + + +# -- Cache locking ------------------------------------------------------------- + + +def test_lock_file_created_during_clone(tmp_path: Path): + from openhands.sdk.git.cached_repo import try_cached_clone_or_update + + cache_dir = tmp_path / "cache" + repo_path = cache_dir / "test-repo" + + mock_git = create_autospec(GitHelper, instance=True) + lock_existed_during_clone: list[bool] = [] + + def mock_clone(url, dest, depth=None, branch=None, timeout=120): + lock_path = repo_path.with_suffix(".lock") + lock_existed_during_clone.append(lock_path.exists()) + + mock_git.clone.side_effect = mock_clone + + try_cached_clone_or_update( + url="https://github.com/test/repo.git", + repo_path=repo_path, + git_helper=mock_git, + ) + + assert lock_existed_during_clone[0] is True + + +def test_lock_timeout_returns_none(tmp_path: Path): + from filelock import FileLock + + from openhands.sdk.git.cached_repo import try_cached_clone_or_update + + cache_dir = tmp_path / "cache" + cache_dir.mkdir(parents=True) + repo_path = cache_dir / "test-repo" + + lock_path = repo_path.with_suffix(".lock") + external_lock = FileLock(lock_path) + external_lock.acquire() + + try: + mock_git = create_autospec(GitHelper, instance=True) + + result = try_cached_clone_or_update( + url="https://github.com/test/repo.git", + repo_path=repo_path, + git_helper=mock_git, + lock_timeout=0.1, + ) + + assert result is None + mock_git.clone.assert_not_called() + finally: + external_lock.release() + + +def test_lock_released_after_operation(tmp_path: Path): + from filelock import FileLock + + from openhands.sdk.git.cached_repo import try_cached_clone_or_update + + cache_dir = tmp_path / "cache" + repo_path = cache_dir / "test-repo" + + mock_git = create_autospec(GitHelper, instance=True) + + try_cached_clone_or_update( + url="https://github.com/test/repo.git", + repo_path=repo_path, + git_helper=mock_git, + ) + + lock_path = repo_path.with_suffix(".lock") + lock = FileLock(lock_path) + lock.acquire(timeout=0) + lock.release() + + +def test_lock_released_on_error(tmp_path: Path): + from filelock import FileLock + + from openhands.sdk.git.cached_repo import try_cached_clone_or_update + + cache_dir = tmp_path / "cache" + repo_path = cache_dir / "test-repo" + + mock_git = create_autospec(GitHelper, instance=True) + mock_git.clone.side_effect = GitCommandError( + "Clone failed", command=["git", "clone"], exit_code=1, stderr="error" + ) + + result = try_cached_clone_or_update( + url="https://github.com/test/repo.git", + repo_path=repo_path, + git_helper=mock_git, + ) + + assert result is None + + lock_path = repo_path.with_suffix(".lock") + lock = FileLock(lock_path) + lock.acquire(timeout=0) + lock.release() diff --git a/tests/sdk/plugin/test_installed_plugins.py b/tests/sdk/plugin/test_installed_plugins.py index e829dfc2ae..a9dc3c00a9 100644 --- a/tests/sdk/plugin/test_installed_plugins.py +++ b/tests/sdk/plugin/test_installed_plugins.py @@ -14,6 +14,7 @@ import pytest +from openhands.sdk.extensions.fetch import get_cache_path, parse_extension_source from openhands.sdk.plugin import ( InstalledPluginInfo, InstalledPluginsMetadata, @@ -29,7 +30,7 @@ uninstall_plugin, update_plugin, ) -from openhands.sdk.plugin.fetch import get_cache_path, parse_plugin_source +from openhands.sdk.plugin.fetch import DEFAULT_CACHE_DIR as DEFAULT_PLUGIN_CACHE_DIR # ============================================================================ @@ -571,8 +572,8 @@ def test_install_document_skills_plugin(installed_dir: Path) -> None: installed_dir=installed_dir, ) - _, url = parse_plugin_source(source) - expected_name = get_cache_path(url).name + _, url = parse_extension_source(source) + expected_name = get_cache_path(url, DEFAULT_PLUGIN_CACHE_DIR).name assert info.name == expected_name assert info.source == source diff --git a/tests/sdk/plugin/test_plugin_fetch.py b/tests/sdk/plugin/test_plugin_fetch.py index 5d072787b7..23fb40e697 100644 --- a/tests/sdk/plugin/test_plugin_fetch.py +++ b/tests/sdk/plugin/test_plugin_fetch.py @@ -1,1038 +1,100 @@ -"""Tests for Plugin.fetch() functionality.""" +"""Tests for plugin-specific fetch behavior. + +Verifies that the plugin fetch layer correctly wraps extensions.fetch with +plugin-specific error types (PluginFetchError), the plugin DEFAULT_CACHE_DIR, +and the Plugin.fetch() classmethod. + +Core fetch logic (parsing, caching, git operations) is tested in +tests/sdk/extensions/test_fetch.py. Git infrastructure (clone, update, +checkout, locking) is tested in tests/sdk/git/test_cached_repo.py. +""" -import subprocess from pathlib import Path from unittest.mock import create_autospec, patch import pytest -from openhands.sdk.git.cached_repo import ( - GitHelper, - _checkout_ref, - _clone_repository, - _update_repository, -) +from openhands.sdk.git.cached_repo import GitHelper from openhands.sdk.git.exceptions import GitCommandError -from openhands.sdk.git.utils import extract_repo_name -from openhands.sdk.plugin import ( - Plugin, - PluginFetchError, -) -from openhands.sdk.plugin.fetch import ( - fetch_plugin, - get_cache_path, - parse_plugin_source, -) - - -class TestParsePluginSource: - """Tests for parse_plugin_source function.""" - - def test_github_shorthand(self): - """Test parsing GitHub shorthand format.""" - source_type, url = parse_plugin_source("github:owner/repo") - assert source_type == "github" - assert url == "https://github.com/owner/repo.git" - - def test_github_shorthand_with_whitespace(self): - """Test parsing GitHub shorthand with leading/trailing whitespace.""" - source_type, url = parse_plugin_source(" github:owner/repo ") - assert source_type == "github" - assert url == "https://github.com/owner/repo.git" - - def test_github_shorthand_invalid_format(self): - """Test that invalid GitHub shorthand raises error.""" - with pytest.raises(PluginFetchError, match="Invalid GitHub shorthand"): - parse_plugin_source("github:invalid") - - with pytest.raises(PluginFetchError, match="Invalid GitHub shorthand"): - parse_plugin_source("github:too/many/parts") - - def test_https_git_url(self): - """Test parsing HTTPS git URLs.""" - source_type, url = parse_plugin_source("https://github.com/owner/repo.git") - assert source_type == "git" - assert url == "https://github.com/owner/repo.git" - - def test_https_github_url_without_git_suffix(self): - """Test parsing GitHub HTTPS URL without .git suffix.""" - source_type, url = parse_plugin_source("https://github.com/owner/repo") - assert source_type == "git" - assert url == "https://github.com/owner/repo.git" - - def test_https_github_url_with_trailing_slash(self): - """Test parsing GitHub HTTPS URL with trailing slash.""" - source_type, url = parse_plugin_source("https://github.com/owner/repo/") - assert source_type == "git" - assert url == "https://github.com/owner/repo.git" - - def test_https_gitlab_url(self): - """Test parsing GitLab HTTPS URLs.""" - source_type, url = parse_plugin_source("https://gitlab.com/org/repo") - assert source_type == "git" - assert url == "https://gitlab.com/org/repo.git" - - def test_https_bitbucket_url(self): - """Test parsing Bitbucket HTTPS URLs.""" - source_type, url = parse_plugin_source("https://bitbucket.org/org/repo") - assert source_type == "git" - assert url == "https://bitbucket.org/org/repo.git" - - def test_ssh_git_url(self): - """Test parsing SSH git URLs.""" - source_type, url = parse_plugin_source("git@github.com:owner/repo.git") - assert source_type == "git" - assert url == "git@github.com:owner/repo.git" - - def test_git_protocol_url(self): - """Test parsing git:// protocol URLs.""" - source_type, url = parse_plugin_source("git://github.com/owner/repo.git") - assert source_type == "git" - assert url == "git://github.com/owner/repo.git" - - def test_absolute_local_path(self): - """Test parsing absolute local paths.""" - source_type, url = parse_plugin_source("/path/to/plugin") - assert source_type == "local" - assert url == "/path/to/plugin" - - def test_home_relative_path(self): - """Test parsing home-relative paths.""" - source_type, url = parse_plugin_source("~/plugins/my-plugin") - assert source_type == "local" - assert url == "~/plugins/my-plugin" - - def test_relative_path(self): - """Test parsing relative paths.""" - source_type, url = parse_plugin_source("./plugins/my-plugin") - assert source_type == "local" - assert url == "./plugins/my-plugin" - - def test_invalid_source(self): - """Test that unparseable sources raise error.""" - with pytest.raises(PluginFetchError, match="Unable to parse plugin source"): - parse_plugin_source("invalid-source-format") - - def test_self_hosted_git_url(self): - """Test parsing URLs from self-hosted/alternative git providers.""" - # Codeberg - source_type, url = parse_plugin_source("https://codeberg.org/user/repo") - assert source_type == "git" - assert url == "https://codeberg.org/user/repo.git" - - # Self-hosted GitLab - source_type, url = parse_plugin_source("https://git.mycompany.com/org/repo") - assert source_type == "git" - assert url == "https://git.mycompany.com/org/repo.git" - - # SourceHut - source_type, url = parse_plugin_source("https://sr.ht/~user/repo") - assert source_type == "git" - assert url == "https://sr.ht/~user/repo.git" - - def test_http_url(self): - """Test parsing plain HTTP URLs (some internal servers).""" - source_type, url = parse_plugin_source("http://internal-git.local/repo") - assert source_type == "git" - assert url == "http://internal-git.local/repo.git" - - def test_ssh_with_custom_user(self): - """Test SSH URLs with non-git usernames.""" - ssh_url = "deploy@git.example.com:project/repo.git" - source_type, url = parse_plugin_source(ssh_url) - assert source_type == "git" - assert url == ssh_url - - -class TestExtractRepoName: - """Tests for extract_repo_name function (in git.utils).""" - - def test_github_shorthand(self): - """Test extracting name from GitHub shorthand.""" - name = extract_repo_name("github:owner/my-plugin") - assert name == "my-plugin" - - def test_https_url(self): - """Test extracting name from HTTPS URL.""" - name = extract_repo_name("https://github.com/owner/my-plugin.git") - assert name == "my-plugin" - - def test_ssh_url(self): - """Test extracting name from SSH URL.""" - name = extract_repo_name("git@github.com:owner/my-plugin.git") - assert name == "my-plugin" - - def test_local_path(self): - """Test extracting name from local path.""" - name = extract_repo_name("/path/to/my-plugin") - assert name == "my-plugin" - - def test_special_characters_sanitized(self): - """Test that special characters are sanitized.""" - name = extract_repo_name("https://github.com/owner/my.special@plugin!.git") - assert name == "my-special-plugin" - - def test_long_name_truncated(self): - """Test that long names are truncated.""" - name = extract_repo_name( - "github:owner/this-is-a-very-long-plugin-name-that-should-be-truncated" - ) - assert len(name) <= 32 - - -class TestGetCachePath: - """Tests for get_cache_path function.""" - - def test_deterministic_path(self, tmp_path: Path): - """Test that cache path is deterministic for same source.""" - source = "https://github.com/owner/repo.git" - path1 = get_cache_path(source, tmp_path) - path2 = get_cache_path(source, tmp_path) - assert path1 == path2 - - def test_different_sources_different_paths(self, tmp_path: Path): - """Test that different sources get different paths.""" - path1 = get_cache_path("https://github.com/owner/repo1.git", tmp_path) - path2 = get_cache_path("https://github.com/owner/repo2.git", tmp_path) - assert path1 != path2 - - def test_path_includes_readable_name(self, tmp_path: Path): - """Test that cache path includes readable name.""" - source = "https://github.com/owner/my-plugin.git" - path = get_cache_path(source, tmp_path) - assert "my-plugin" in path.name - - def test_default_cache_dir(self): - """Test that default cache dir is under ~/.openhands/cache/plugins/.""" - source = "https://github.com/owner/repo.git" - path = get_cache_path(source) - assert ".openhands" in str(path) - assert "cache" in str(path) - assert "plugins" in str(path) - - -class TestCloneRepository: - """Tests for _clone_repository function.""" - - def test_clone_calls_git_helper(self, tmp_path: Path): - """Test that clone delegates to GitHelper.""" - mock_git = create_autospec(GitHelper) - dest = tmp_path / "repo" - - _clone_repository("https://github.com/owner/repo.git", dest, None, mock_git) - - mock_git.clone.assert_called_once_with( - "https://github.com/owner/repo.git", dest, depth=1, branch=None - ) - - def test_clone_with_ref(self, tmp_path: Path): - """Test clone with branch/tag ref.""" - mock_git = create_autospec(GitHelper) - dest = tmp_path / "repo" - - _clone_repository("https://github.com/owner/repo.git", dest, "v1.0.0", mock_git) - - mock_git.clone.assert_called_once_with( - "https://github.com/owner/repo.git", dest, depth=1, branch="v1.0.0" - ) - - def test_clone_removes_existing_directory(self, tmp_path: Path): - """Test that existing non-git directory is removed.""" - mock_git = create_autospec(GitHelper) - dest = tmp_path / "repo" - dest.mkdir() - (dest / "some_file.txt").write_text("test") - - _clone_repository("https://github.com/owner/repo.git", dest, None, mock_git) - - mock_git.clone.assert_called_once() - - -class TestUpdateRepository: - """Tests for _update_repository function.""" - - def test_update_fetches_and_resets(self, tmp_path: Path): - """Test update fetches from origin and resets to branch.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = "main" - - _update_repository(tmp_path, None, mock_git) - - mock_git.fetch.assert_called_once_with(tmp_path) - mock_git.get_current_branch.assert_called_once_with(tmp_path) - mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") - - def test_update_with_ref_checks_out(self, tmp_path: Path): - """Test update with ref checks out that ref.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = None # Assume tag/commit (detached) - - _update_repository(tmp_path, "v1.0.0", mock_git) - - # fetch is called once in _update_repository (checkout_ref no longer fetches) - mock_git.fetch.assert_called_once_with(tmp_path) - mock_git.checkout.assert_called_once_with(tmp_path, "v1.0.0") - - def test_update_detached_head_recovers_to_default_branch(self, tmp_path: Path): - """Test update recovers from detached HEAD by checking out default branch. - - When a repo is in detached HEAD state (e.g., from a previous checkout of a - tag) and update is called without a ref, it should: - 1. Detect the detached HEAD state - 2. Determine the remote's default branch via origin/HEAD - 3. Checkout and reset to that default branch - - This ensures that `fetch(source, update=True)` without a ref means "get the - latest from the default branch", not "stay stuck on whatever was previously - checked out". - """ - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = None # Detached HEAD - mock_git.get_default_branch.return_value = "main" - - _update_repository(tmp_path, None, mock_git) - - mock_git.fetch.assert_called_once() - mock_git.get_current_branch.assert_called_once() - mock_git.get_default_branch.assert_called_once_with(tmp_path) - mock_git.checkout.assert_called_once_with(tmp_path, "main") - mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") - - def test_update_detached_head_no_default_branch_logs_warning(self, tmp_path: Path): - """Test update logs warning when detached HEAD and default branch unknown. - - If origin/HEAD is not set (can happen with some git configurations), we - can't determine the default branch. In this case, log a warning and use - the cached version as-is. - """ - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = None # Detached HEAD - mock_git.get_default_branch.return_value = None # Can't determine default - - _update_repository(tmp_path, None, mock_git) - - mock_git.fetch.assert_called_once() - mock_git.get_default_branch.assert_called_once() - # Should not attempt checkout since we don't know the target - mock_git.checkout.assert_not_called() - mock_git.reset_hard.assert_not_called() - - def test_update_continues_on_fetch_error(self, tmp_path: Path): - """Test update logs warning on fetch failure but doesn't raise.""" - mock_git = create_autospec(GitHelper) - mock_git.fetch.side_effect = GitCommandError( - "Network error", command=["git", "fetch"], exit_code=1 - ) - - # Should not raise - graceful degradation - _update_repository(tmp_path, None, mock_git) - - # Fetch was attempted - mock_git.fetch.assert_called_once() - # No further operations since fetch failed - mock_git.get_current_branch.assert_not_called() - - def test_update_continues_on_checkout_error(self, tmp_path: Path): - """Test update logs warning on checkout failure but doesn't raise.""" - mock_git = create_autospec(GitHelper) - mock_git.checkout.side_effect = GitCommandError( - "Invalid ref", command=["git", "checkout"], exit_code=1 - ) - - # Should not raise - graceful degradation - _update_repository(tmp_path, "nonexistent", mock_git) - - -class TestCheckoutRef: - """Tests for _checkout_ref function. - - The function detects ref type AFTER checkout by checking HEAD state: - - Detached HEAD (None from get_current_branch) = tag or commit - - On a branch = reset to origin/{branch} - """ - - def test_checkout_branch_resets_to_origin(self, tmp_path: Path): - """Test checkout of a branch resets to origin.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = "main" # On a branch - - _checkout_ref(tmp_path, "main", mock_git) - - mock_git.checkout.assert_called_once_with(tmp_path, "main") - mock_git.get_current_branch.assert_called_once_with(tmp_path) - mock_git.reset_hard.assert_called_once_with(tmp_path, "origin/main") - - def test_checkout_tag_skips_reset(self, tmp_path: Path): - """Test checkout of a tag (detached HEAD) skips reset.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = None # Detached HEAD - - _checkout_ref(tmp_path, "v1.0.0", mock_git) - - mock_git.checkout.assert_called_once_with(tmp_path, "v1.0.0") - mock_git.get_current_branch.assert_called_once_with(tmp_path) - mock_git.reset_hard.assert_not_called() - - def test_checkout_commit_skips_reset(self, tmp_path: Path): - """Test checkout of a commit SHA (detached HEAD) skips reset.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = None # Detached HEAD - - _checkout_ref(tmp_path, "abc123def", mock_git) - - mock_git.checkout.assert_called_once_with(tmp_path, "abc123def") - mock_git.reset_hard.assert_not_called() - - def test_checkout_branch_handles_reset_error(self, tmp_path: Path): - """Test checkout continues if reset fails (e.g., branch not on remote).""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = "local-only" - mock_git.reset_hard.side_effect = GitCommandError( - "Not found", command=["git", "reset"], exit_code=1 - ) - - # Should not raise - reset failure is logged but not fatal - _checkout_ref(tmp_path, "local-only", mock_git) - - mock_git.checkout.assert_called_once() - - -class TestFetchPlugin: - """Tests for fetch_plugin function.""" - - def test_fetch_local_path(self, tmp_path: Path): - """Test fetching from local path returns the path unchanged.""" - plugin_dir = tmp_path / "my-plugin" - plugin_dir.mkdir() - - result = fetch_plugin(str(plugin_dir)) - assert result == plugin_dir.resolve() - - def test_fetch_local_path_nonexistent(self, tmp_path: Path): - """Test fetching nonexistent local path raises error.""" - with pytest.raises(PluginFetchError, match="does not exist"): - fetch_plugin(str(tmp_path / "nonexistent")) - - def test_fetch_github_shorthand_clones(self, tmp_path: Path): - """Test fetching GitHub shorthand clones the repository.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - - mock_git.clone.side_effect = clone_side_effect - - result = fetch_plugin( - "github:owner/repo", cache_dir=tmp_path, git_helper=mock_git - ) - - assert result.exists() - mock_git.clone.assert_called_once() - call_args = mock_git.clone.call_args - assert call_args[0][0] == "https://github.com/owner/repo.git" - - def test_fetch_with_ref(self, tmp_path: Path): - """Test fetching with specific ref.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - - mock_git.clone.side_effect = clone_side_effect - - fetch_plugin( - "github:owner/repo", cache_dir=tmp_path, ref="v1.0.0", git_helper=mock_git - ) - - mock_git.clone.assert_called_once() - call_kwargs = mock_git.clone.call_args[1] - assert call_kwargs["branch"] == "v1.0.0" - - def test_fetch_updates_existing_cache(self, tmp_path: Path): - """Test that fetch updates existing cached repository.""" - mock_git = create_autospec(GitHelper) - mock_git.get_current_branch.return_value = "main" - - cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) - cache_path.mkdir(parents=True) - (cache_path / ".git").mkdir() - - result = fetch_plugin( - "github:owner/repo", cache_dir=tmp_path, update=True, git_helper=mock_git - ) - - assert result == cache_path - mock_git.fetch.assert_called() - mock_git.clone.assert_not_called() - - def test_fetch_no_update_uses_cache(self, tmp_path: Path): - """Test that fetch with update=False uses cached version.""" - mock_git = create_autospec(GitHelper) - - cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) - cache_path.mkdir(parents=True) - (cache_path / ".git").mkdir() - - result = fetch_plugin( - "github:owner/repo", cache_dir=tmp_path, update=False, git_helper=mock_git - ) - - assert result == cache_path - mock_git.clone.assert_not_called() - mock_git.fetch.assert_not_called() +from openhands.sdk.plugin import Plugin, PluginFetchError +from openhands.sdk.plugin.fetch import fetch_plugin - def test_fetch_no_update_with_ref_checks_out(self, tmp_path: Path): - """Test that fetch with update=False but ref still checks out.""" - mock_git = create_autospec(GitHelper) - cache_path = get_cache_path("https://github.com/owner/repo.git", tmp_path) - cache_path.mkdir(parents=True) - (cache_path / ".git").mkdir() +def test_fetch_git_error_raises_plugin_fetch_error(tmp_path: Path): + """ExtensionFetchError from git failures is wrapped as PluginFetchError.""" + mock_git = create_autospec(GitHelper, instance=True) + mock_git.clone.side_effect = GitCommandError( + "fatal: repository not found", + command=["git", "clone"], + exit_code=128, + ) + with pytest.raises(PluginFetchError, match="Failed to fetch plugin"): fetch_plugin( - "github:owner/repo", + "github:owner/nonexistent", cache_dir=tmp_path, - update=False, - ref="v1.0.0", git_helper=mock_git, ) - mock_git.checkout.assert_called_once_with(cache_path, "v1.0.0") - - def test_fetch_git_error_raises_plugin_fetch_error(self, tmp_path: Path): - """Test that git errors result in PluginFetchError.""" - mock_git = create_autospec(GitHelper) - mock_git.clone.side_effect = GitCommandError( - "fatal: repository not found", command=["git", "clone"], exit_code=128 - ) - - with pytest.raises(PluginFetchError, match="Failed to fetch plugin"): - fetch_plugin( - "github:owner/nonexistent", cache_dir=tmp_path, git_helper=mock_git - ) - - def test_fetch_generic_error_raises_plugin_fetch_error(self, tmp_path: Path): - """Test that generic errors result in PluginFetchError.""" - mock_git = create_autospec(GitHelper) - mock_git.clone.side_effect = RuntimeError("Unexpected error") - - with pytest.raises(PluginFetchError, match="Failed to fetch plugin"): - fetch_plugin("github:owner/repo", cache_dir=tmp_path, git_helper=mock_git) - - -class TestPluginFetchMethod: - """Tests for Plugin.fetch() classmethod.""" - - def test_fetch_delegates_to_fetch_plugin(self, tmp_path: Path): - """Test that Plugin.fetch() delegates to fetch_plugin.""" - plugin_dir = tmp_path / "my-plugin" - plugin_dir.mkdir() - - result = Plugin.fetch(str(plugin_dir)) - assert result == plugin_dir.resolve() - - def test_fetch_local_path_with_tilde(self, tmp_path: Path): - """Test fetching local path with ~ expansion.""" - plugin_dir = tmp_path / "my-plugin" - plugin_dir.mkdir() - - with patch("openhands.sdk.plugin.fetch.Path.home", return_value=tmp_path): - result = Plugin.fetch(str(plugin_dir)) - assert result.exists() - - -class TestRepoPathParameter: - """Tests for repo_path parameter in fetch_plugin() and Plugin.fetch().""" - - def test_fetch_local_path_with_repo_path_raises_error(self, tmp_path: Path): - """Test that repo_path is not supported for local sources.""" - plugin_dir = tmp_path / "monorepo" - plugin_dir.mkdir() - subplugin_dir = plugin_dir / "plugins" / "my-plugin" - subplugin_dir.mkdir(parents=True) - - with pytest.raises( - PluginFetchError, match="repo_path is not supported for local" - ): - fetch_plugin(str(plugin_dir), repo_path="plugins/my-plugin") - def test_fetch_local_path_without_repo_path(self, tmp_path: Path): - """Test fetching local path works without repo_path.""" - plugin_dir = tmp_path / "my-plugin" - plugin_dir.mkdir() +def test_fetch_generic_error_raises_plugin_fetch_error(tmp_path: Path): + """Generic runtime errors are also wrapped as PluginFetchError.""" + mock_git = create_autospec(GitHelper, instance=True) + mock_git.clone.side_effect = RuntimeError("Unexpected error") - result = fetch_plugin(str(plugin_dir)) - assert result == plugin_dir.resolve() - - def test_fetch_github_with_repo_path(self, tmp_path: Path): - """Test fetching from GitHub with repo_path returns subdirectory.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - # Create the subdirectory structure - subdir = dest / "plugins" / "sub-plugin" - subdir.mkdir(parents=True) - - mock_git.clone.side_effect = clone_side_effect - - result = fetch_plugin( - "github:owner/monorepo", + with pytest.raises(PluginFetchError, match="Failed to fetch plugin"): + fetch_plugin( + "github:owner/repo", cache_dir=tmp_path, - repo_path="plugins/sub-plugin", git_helper=mock_git, ) - assert result.exists() - assert result.name == "sub-plugin" - assert "plugins" in str(result) - - def test_fetch_github_with_nonexistent_repo_path(self, tmp_path: Path): - """Test fetching from GitHub with nonexistent repo_path raises error.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - - mock_git.clone.side_effect = clone_side_effect - - with pytest.raises(PluginFetchError, match="Subdirectory.*not found"): - fetch_plugin( - "github:owner/repo", - cache_dir=tmp_path, - repo_path="nonexistent", - git_helper=mock_git, - ) - - def test_fetch_with_repo_path_and_ref(self, tmp_path: Path): - """Test fetching with both repo_path and ref.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - subdir = dest / "plugins" / "my-plugin" - subdir.mkdir(parents=True) - - mock_git.clone.side_effect = clone_side_effect - - result = fetch_plugin( - "github:owner/monorepo", - cache_dir=tmp_path, - ref="v1.0.0", - repo_path="plugins/my-plugin", - git_helper=mock_git, - ) - assert result.exists() - mock_git.clone.assert_called_once() - call_kwargs = mock_git.clone.call_args[1] - assert call_kwargs["branch"] == "v1.0.0" +def test_fetch_local_with_repo_path_raises_plugin_fetch_error( + tmp_path: Path, +): + """repo_path rejection for local sources surfaces as PluginFetchError.""" + plugin_dir = tmp_path / "monorepo" + plugin_dir.mkdir() - def test_plugin_fetch_local_with_repo_path_raises_error(self, tmp_path: Path): - """Test Plugin.fetch() raises error for local source with repo_path.""" - plugin_dir = tmp_path / "monorepo" - plugin_dir.mkdir() + with pytest.raises(PluginFetchError, match="repo_path is not supported for local"): + fetch_plugin(str(plugin_dir), repo_path="plugins/my-plugin") - with pytest.raises( - PluginFetchError, match="repo_path is not supported for local" - ): - Plugin.fetch(str(plugin_dir), repo_path="plugins/my-plugin") - def test_fetch_no_repo_path_returns_root(self, tmp_path: Path): - """Test that fetch without repo_path returns repository root.""" - mock_git = create_autospec(GitHelper) +def test_fetch_uses_default_cache_dir(tmp_path: Path): + """fetch_plugin uses the plugin-specific DEFAULT_CACHE_DIR.""" + mock_git = create_autospec(GitHelper, instance=True) - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - (dest / "plugins").mkdir() + def clone_side_effect(url, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() - mock_git.clone.side_effect = clone_side_effect + mock_git.clone.side_effect = clone_side_effect + with patch("openhands.sdk.plugin.fetch.DEFAULT_CACHE_DIR", tmp_path / "cache"): result = fetch_plugin( "github:owner/repo", - cache_dir=tmp_path, - repo_path=None, + cache_dir=None, git_helper=mock_git, ) - assert result.exists() - assert (result / ".git").exists() - - -class TestParsePluginSourceEdgeCases: - """Additional edge case tests for parse_plugin_source.""" - - def test_relative_path_with_slash(self): - """Test parsing paths like 'plugins/my-plugin' (line 108).""" - source_type, url = parse_plugin_source("plugins/my-plugin") - assert source_type == "local" - assert url == "plugins/my-plugin" - - def test_nested_relative_path(self): - """Test parsing nested relative paths.""" - source_type, url = parse_plugin_source("path/to/my/plugin") - assert source_type == "local" - assert url == "path/to/my/plugin" - - -class TestFetchPluginEdgeCases: - """Additional edge case tests for fetch_plugin.""" - - def test_fetch_uses_default_cache_dir(self, tmp_path: Path): - """Test fetch_plugin uses DEFAULT_CACHE_DIR when cache_dir is None.""" - mock_git = create_autospec(GitHelper) - - def clone_side_effect(url, dest, **kwargs): - dest.mkdir(parents=True, exist_ok=True) - (dest / ".git").mkdir() - - mock_git.clone.side_effect = clone_side_effect - - # Patch DEFAULT_CACHE_DIR to use tmp_path - with patch("openhands.sdk.plugin.fetch.DEFAULT_CACHE_DIR", tmp_path / "cache"): - result = fetch_plugin( - "github:owner/repo", - cache_dir=None, # Explicitly None to trigger line 225 - git_helper=mock_git, - ) - - assert result.exists() - assert str(tmp_path / "cache") in str(result) - - -class TestGitHelperErrors: - """Tests for GitHelper error handling paths. - - These tests verify that GitHelper methods properly propagate GitCommandError - from run_git_command when git operations fail. - """ - - def test_clone_called_process_error(self, tmp_path: Path): - """Test clone raises GitCommandError on failure.""" - git = GitHelper() - dest = tmp_path / "repo" - - # Try to clone a non-existent repo - with pytest.raises(GitCommandError, match="git clone"): - git.clone("https://invalid.example.com/nonexistent.git", dest, timeout=5) - - def test_clone_timeout(self, tmp_path: Path): - """Test clone raises GitCommandError on timeout.""" - git = GitHelper() - dest = tmp_path / "repo" - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) - with pytest.raises(GitCommandError, match="timed out"): - git.clone("https://github.com/owner/repo.git", dest, timeout=1) - - def test_fetch_with_ref(self, tmp_path: Path): - """Test fetch with ref raises GitCommandError when no remote exists.""" - # Create a repo to fetch in - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], cwd=repo, check=True - ) - subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True) - (repo / "file.txt").write_text("content") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo, check=True) - - git = GitHelper() - # This will fail because there's no remote - with pytest.raises(GitCommandError, match="git fetch"): - git.fetch(repo, ref="main") - - def test_fetch_called_process_error(self, tmp_path: Path): - """Test fetch raises GitCommandError on failure.""" - git = GitHelper() - repo = tmp_path / "not-a-repo" - repo.mkdir() - - with pytest.raises(GitCommandError, match="git fetch"): - git.fetch(repo) - - def test_fetch_timeout(self, tmp_path: Path): - """Test fetch raises GitCommandError on timeout.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) - with pytest.raises(GitCommandError, match="timed out"): - git.fetch(repo, timeout=1) - - def test_checkout_called_process_error(self, tmp_path: Path): - """Test checkout raises GitCommandError on failure.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True) + assert result.exists() + assert str(tmp_path / "cache") in str(result) - with pytest.raises(GitCommandError, match="git checkout"): - git.checkout(repo, "nonexistent-ref") - def test_checkout_timeout(self, tmp_path: Path): - """Test checkout raises GitCommandError on timeout.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() +def test_plugin_fetch_delegates(tmp_path: Path): + """Plugin.fetch() delegates to fetch_plugin for local paths.""" + plugin_dir = tmp_path / "my-plugin" + plugin_dir.mkdir() - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) - with pytest.raises(GitCommandError, match="timed out"): - git.checkout(repo, "main", timeout=1) + result = Plugin.fetch(str(plugin_dir)) + assert result == plugin_dir.resolve() - def test_reset_hard_called_process_error(self, tmp_path: Path): - """Test reset_hard raises GitCommandError on failure.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True) - - with pytest.raises(GitCommandError, match="git reset"): - git.reset_hard(repo, "nonexistent-ref") - - def test_reset_hard_timeout(self, tmp_path: Path): - """Test reset_hard raises GitCommandError on timeout.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) - with pytest.raises(GitCommandError, match="timed out"): - git.reset_hard(repo, "HEAD", timeout=1) - - def test_get_current_branch_called_process_error(self, tmp_path: Path): - """Test get_current_branch raises GitCommandError on failure.""" - git = GitHelper() - repo = tmp_path / "not-a-repo" - repo.mkdir() - - with pytest.raises(GitCommandError, match="git rev-parse"): - git.get_current_branch(repo) - - def test_get_current_branch_timeout(self, tmp_path: Path): - """Test get_current_branch raises GitCommandError on timeout.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd=["git"], timeout=1) - with pytest.raises(GitCommandError, match="timed out"): - git.get_current_branch(repo, timeout=1) - - -class TestGetDefaultBranch: - """Tests for GitHelper.get_default_branch method.""" - - def test_get_default_branch_returns_main(self, tmp_path: Path): - """Test get_default_branch extracts branch name from origin/HEAD.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_result = subprocess.CompletedProcess( - args=["git"], - returncode=0, - stdout="refs/remotes/origin/main\n", - stderr="", - ) - mock_run.return_value = mock_result - - result = git.get_default_branch(repo) - - assert result == "main" - # Verify the correct command was called - call_args = mock_run.call_args[0][0] - assert call_args == ["git", "symbolic-ref", "refs/remotes/origin/HEAD"] - - def test_get_default_branch_returns_master(self, tmp_path: Path): - """Test get_default_branch works with master as default branch.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_result = subprocess.CompletedProcess( - args=["git"], - returncode=0, - stdout="refs/remotes/origin/master\n", - stderr="", - ) - mock_run.return_value = mock_result - - result = git.get_default_branch(repo) - - assert result == "master" - - def test_get_default_branch_returns_none_when_not_set(self, tmp_path: Path): - """Test get_default_branch returns None when origin/HEAD is not set. - - This can happen with: - - Bare clones - - Repos where origin/HEAD was never configured - - Some git server configurations - """ - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_result = subprocess.CompletedProcess( - args=["git"], - returncode=1, - stdout="", - stderr="fatal: ref refs/remotes/origin/HEAD is not a symbolic ref", - ) - mock_run.return_value = mock_result - - result = git.get_default_branch(repo) - - assert result is None - - def test_get_default_branch_returns_none_on_unexpected_format(self, tmp_path: Path): - """Test get_default_branch returns None for unexpected output format.""" - git = GitHelper() - repo = tmp_path / "repo" - repo.mkdir() - - with patch("openhands.sdk.git.utils.subprocess.run") as mock_run: - mock_result = subprocess.CompletedProcess( - args=["git"], - returncode=0, - stdout="unexpected-format\n", # Doesn't start with expected prefix - stderr="", - ) - mock_run.return_value = mock_result - - result = git.get_default_branch(repo) - - assert result is None - - -class TestCacheLocking: - """Tests for cache directory locking behavior.""" - - def test_lock_file_created_during_clone(self, tmp_path: Path): - """Test that a lock file is created when cloning.""" - from openhands.sdk.git.cached_repo import try_cached_clone_or_update - - cache_dir = tmp_path / "cache" - repo_path = cache_dir / "test-repo" - - mock_git = create_autospec(GitHelper, instance=True) - - # Track whether lock file exists during clone - lock_existed_during_clone = [] - - def mock_clone(url, dest, depth=None, branch=None, timeout=120): - lock_path = repo_path.with_suffix(".lock") - lock_existed_during_clone.append(lock_path.exists()) - - mock_git.clone.side_effect = mock_clone - - try_cached_clone_or_update( - url="https://github.com/test/repo.git", - repo_path=repo_path, - git_helper=mock_git, - ) - - # Lock file should have existed during the clone operation - assert lock_existed_during_clone[0] is True - - def test_lock_timeout_returns_none(self, tmp_path: Path): - """Test that lock timeout returns None gracefully.""" - from filelock import FileLock - - from openhands.sdk.git.cached_repo import try_cached_clone_or_update - - cache_dir = tmp_path / "cache" - cache_dir.mkdir(parents=True) - repo_path = cache_dir / "test-repo" - - # Pre-acquire the lock to simulate another process holding it - lock_path = repo_path.with_suffix(".lock") - external_lock = FileLock(lock_path) - external_lock.acquire() - - try: - mock_git = create_autospec(GitHelper, instance=True) - - # Try to clone with a very short timeout - result = try_cached_clone_or_update( - url="https://github.com/test/repo.git", - repo_path=repo_path, - git_helper=mock_git, - lock_timeout=0.1, # Very short timeout - ) - - # Should return None due to lock timeout - assert result is None - # Clone should not have been called - mock_git.clone.assert_not_called() - finally: - external_lock.release() - - def test_lock_released_after_operation(self, tmp_path: Path): - """Test that lock is released after successful operation.""" - from filelock import FileLock - - from openhands.sdk.git.cached_repo import try_cached_clone_or_update - - cache_dir = tmp_path / "cache" - repo_path = cache_dir / "test-repo" - - mock_git = create_autospec(GitHelper, instance=True) - - try_cached_clone_or_update( - url="https://github.com/test/repo.git", - repo_path=repo_path, - git_helper=mock_git, - ) - - # Lock should be released - we should be able to acquire it immediately - lock_path = repo_path.with_suffix(".lock") - lock = FileLock(lock_path) - lock.acquire(timeout=0) # Should not block - lock.release() - - def test_lock_released_on_error(self, tmp_path: Path): - """Test that lock is released even when operation fails.""" - from filelock import FileLock - - from openhands.sdk.git.cached_repo import try_cached_clone_or_update - - cache_dir = tmp_path / "cache" - repo_path = cache_dir / "test-repo" - - mock_git = create_autospec(GitHelper, instance=True) - mock_git.clone.side_effect = GitCommandError( - "Clone failed", command=["git", "clone"], exit_code=1, stderr="error" - ) - - result = try_cached_clone_or_update( - url="https://github.com/test/repo.git", - repo_path=repo_path, - git_helper=mock_git, - ) - assert result is None +def test_plugin_fetch_local_with_repo_path_raises_error(tmp_path: Path): + """Plugin.fetch() raises PluginFetchError for local + repo_path.""" + plugin_dir = tmp_path / "monorepo" + plugin_dir.mkdir() - # Lock should still be released - lock_path = repo_path.with_suffix(".lock") - lock = FileLock(lock_path) - lock.acquire(timeout=0) - lock.release() + with pytest.raises(PluginFetchError, match="repo_path is not supported for local"): + Plugin.fetch(str(plugin_dir), repo_path="plugins/my-plugin") From 64605356506a786f7e58995c661a8284120ee5ac Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 11:36:56 -0600 Subject: [PATCH 08/51] cleaning up error messages --- openhands-sdk/openhands/sdk/plugin/fetch.py | 2 +- openhands-sdk/openhands/sdk/skills/fetch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/plugin/fetch.py b/openhands-sdk/openhands/sdk/plugin/fetch.py index 2fcea18577..1310e5717c 100644 --- a/openhands-sdk/openhands/sdk/plugin/fetch.py +++ b/openhands-sdk/openhands/sdk/plugin/fetch.py @@ -107,4 +107,4 @@ def fetch_plugin_with_resolution( git_helper=git_helper, ) except ExtensionFetchError as exc: - raise PluginFetchError(str(exc).replace("extension", "plugin")) from exc + raise PluginFetchError("Failed to fetch plugin") from exc diff --git a/openhands-sdk/openhands/sdk/skills/fetch.py b/openhands-sdk/openhands/sdk/skills/fetch.py index 57618d0e15..3c403c4a1a 100644 --- a/openhands-sdk/openhands/sdk/skills/fetch.py +++ b/openhands-sdk/openhands/sdk/skills/fetch.py @@ -91,4 +91,4 @@ def fetch_skill_with_resolution( git_helper=git_helper, ) except ExtensionFetchError as exc: - raise SkillFetchError(str(exc)) from exc + raise SkillFetchError("Failed to fetch skill") from exc From e1413f5bb7d72b69e1855327563df2cc786c5a38 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Fri, 10 Apr 2026 11:41:00 -0600 Subject: [PATCH 09/51] Fix PluginFetchError to preserve original error message The except block was discarding the ExtensionFetchError message and replacing it with a generic 'Failed to fetch plugin'. Now it carries the original message through with 'extension' replaced by 'plugin'. Co-authored-by: openhands --- openhands-sdk/openhands/sdk/plugin/fetch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/plugin/fetch.py b/openhands-sdk/openhands/sdk/plugin/fetch.py index 1310e5717c..a0ee5d482c 100644 --- a/openhands-sdk/openhands/sdk/plugin/fetch.py +++ b/openhands-sdk/openhands/sdk/plugin/fetch.py @@ -107,4 +107,5 @@ def fetch_plugin_with_resolution( git_helper=git_helper, ) except ExtensionFetchError as exc: - raise PluginFetchError("Failed to fetch plugin") from exc + msg = str(exc).replace("extension", "plugin") + raise PluginFetchError(msg) from exc From 717c854414c77684eb4f4b4376f2934e331c98dd Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 07:37:10 -0600 Subject: [PATCH 10/51] initial installation manager api --- .../openhands/sdk/extensions/installed.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 openhands-sdk/openhands/sdk/extensions/installed.py diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py new file mode 100644 index 0000000000..6b40c79b24 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel, Field + +from openhands.sdk.logger import get_logger + + +logger = get_logger(__name__) + + +class InstalledExtensionMetadata[InfoT](BaseModel): + """Metadata file for tracking installed extensions.""" + + extensions: dict[str, InfoT] = Field( + default_factory=dict, description="Map from extension name to installation info" + ) + + metadata_filename: ClassVar[str] = ".installed.json" + + @classmethod + def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT]: + """Load metadata from the installed extensions directory.""" + metadata_path = installed_dir / cls.metadata_filename + if not metadata_path.exists(): + return cls() + + try: + with metadata_path.open() as f: + data = json.load(f) + return cls.model_validate(data) + + except Exception as e: + logger.warning(f"Failed to load installed extension metadata: {e}") + return cls() + + def save_to_dir(self, installed_dir: Path) -> None: + """Save metadata to the installed extensions directory.""" + metadata_path = installed_dir / self.metadata_filename + metadata_path.parent.mkdir(parents=True, exist_ok=True) + with metadata_path.open("w") as f: + json.dump(self.model_dump(), f, indent=2) + + +class InstalledExtensionManager[T, InfoT]: + def install( + self, + source: str, + ref: str | None = None, + repo_path: str | None = None, + installed_dir: Path | None = None, + force: bool = False, + ) -> InfoT: + raise NotImplementedError() + + def uninstall(self, name: str, installed_dir: Path | None = None) -> bool: + raise NotImplementedError() + + def enable(self, name: str, installed_dir: Path | None = None) -> bool: + raise NotImplementedError() + + def disable(self, name: str, installed_dir: Path | None = None) -> bool: + raise NotImplementedError() + + def list_installed(self, installed_dir: Path | None = None) -> list[InfoT]: + raise NotImplementedError() + + def load_installed(self, installed_dir: Path | None = None) -> list[T]: + raise NotImplementedError() + + def get(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + raise NotImplementedError() + + def update(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + raise NotImplementedError() From ee9fe39a92a6a5d8c4cef1f0916c5cf28e8187a6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 07:59:44 -0600 Subject: [PATCH 11/51] type strengthening --- .../openhands/sdk/extensions/installed.py | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index 6b40c79b24..aee24c8f28 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -1,8 +1,9 @@ from __future__ import annotations import json +from abc import ABC, abstractmethod from pathlib import Path -from typing import ClassVar +from typing import ClassVar, Self from pydantic import BaseModel, Field @@ -12,19 +13,45 @@ logger = get_logger(__name__) -class InstalledExtensionMetadata[InfoT](BaseModel): +class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): + """Base class for information about an installed extension. + + Linked to extensions by the installed extension metadata. + """ + + @classmethod + @abstractmethod + def from_extension( + cls: type[Self], + extension: T, + source: str, + resolved_ref: str | None, + repo_path: str | None, + install_path: Path, + ) -> Self: + """Create installed extension info from a loaded skill.""" + raise NotImplementedError() + + +class InstalledExtensionMetadata[InfoT: InstalledExtensionInfoBaseClass](BaseModel): """Metadata file for tracking installed extensions.""" extensions: dict[str, InfoT] = Field( - default_factory=dict, description="Map from extension name to installation info" + default_factory=dict, + description="Map from extension name to extension installation info", ) metadata_filename: ClassVar[str] = ".installed.json" + @classmethod + def get_metadata_path(cls, installed_dir: Path) -> Path: + """Get the metadata file path for the installed extension directory.""" + return installed_dir / cls.metadata_filename + @classmethod def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT]: """Load metadata from the installed extensions directory.""" - metadata_path = installed_dir / cls.metadata_filename + metadata_path = cls.get_metadata_path(installed_dir) if not metadata_path.exists(): return cls() @@ -39,13 +66,13 @@ def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT] def save_to_dir(self, installed_dir: Path) -> None: """Save metadata to the installed extensions directory.""" - metadata_path = installed_dir / self.metadata_filename + metadata_path = self.get_metadata_path(installed_dir) metadata_path.parent.mkdir(parents=True, exist_ok=True) with metadata_path.open("w") as f: json.dump(self.model_dump(), f, indent=2) -class InstalledExtensionManager[T, InfoT]: +class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass[T]]: def install( self, source: str, From a469127434e99429e6f966dfb6e2de9682b47b85 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 08:13:23 -0600 Subject: [PATCH 12/51] relaxing type def --- openhands-sdk/openhands/sdk/extensions/installed.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index aee24c8f28..3a05907f8b 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -72,7 +72,10 @@ def save_to_dir(self, installed_dir: Path) -> None: json.dump(self.model_dump(), f, indent=2) -class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass[T]]: +# Cannot precisely specify the relationship between T and InfoT without running into +# higher-kinded types (not supported by any version of Python). + +class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass]: def install( self, source: str, From 9399e244df3c575a95fd57b4519fa031a6d942c1 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 08:45:10 -0600 Subject: [PATCH 13/51] installation manager docs first pass, default metadata added --- .../openhands/sdk/extensions/installed.py | 153 +++++++++++++++++- 1 file changed, 150 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index 3a05907f8b..691c91e3e0 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -19,6 +19,23 @@ class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): Linked to extensions by the installed extension metadata. """ + name: str = Field(description="Extension name") + version: str = Field(default="1.0.0", description="Extension version") + + enabled: bool = Field(default=True, description="Whether the extension is enabled") + + source: str = Field(description="Original source (e.g., 'github:owner/repo')") + resolved_ref: str | None = Field( + default=None, description="Resolved git commit SHA (for version pinning)" + ) + repo_path: str | None = Field( + default=None, + description="Subdirectory path within the repository (for monorepos)", + ) + + installed_at: str = Field(description="ISO 8601 timestamp of installation") + install_path: str = Field(description="Path where the extension is installed") + @classmethod @abstractmethod def from_extension( @@ -72,10 +89,15 @@ def save_to_dir(self, installed_dir: Path) -> None: json.dump(self.model_dump(), f, indent=2) -# Cannot precisely specify the relationship between T and InfoT without running into -# higher-kinded types (not supported by any version of Python). +class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass](BaseModel): + """Manages installed extensions.""" + + installed_dir: Path = Field(description="Directory for installed extensions.") + + def _resolved_installed_dir(self, installed_dir: Path | None = None) -> Path: + """Returns installed_dir, or the default if None.""" + return installed_dir if installed_dir is not None else self.installed_dir -class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass]: def install( self, source: str, @@ -84,25 +106,150 @@ def install( installed_dir: Path | None = None, force: bool = False, ) -> InfoT: + """Install an extension from a source. + + Fetches the extensionFrom the source, copies it to the installed extensions + directory, and records the installation metadata. + + Args: + source: Extension source - can be: + - "github:owner/repo" - GitHub shorthand + - Any git URL (GitHub, GitLab, Bitbucket, etc.) + - Local path (for development/testing) + ref: Optional branch, tag, or commit to install. + repo_path: Subdirectory path within the repository (for monorepos). + installed_dir: Optional directory for installed extensions. Defaults to the + installed_dir instance variable. + force: If True, overwrite existing installation. If False, raise error + if extension is already installed. + + Returns: + InstalledExtensionInfoBaseClass[T] instance with details about the + installation. + + Raises: + ExtensionFetchError: If fetching the extension fails. + FileExistsError: If extension is already installed and force=False. + ValueError: If the extension manifest is invalid. + + Example: + >>> info = install("github:owner/my-extension", ref="v1.0.0") + >>> print(f"Installed {info.name} from {info.source}") + """ + raise NotImplementedError() def uninstall(self, name: str, installed_dir: Path | None = None) -> bool: + """Uninstall an extension by name. + + Only extensions tracked in the installed extensions metadata file can be + uninstalled. This avoids deleting arbitrary directories in the installed + extensions directory. + + Args: + name: Name of the extension to uninstall. + installed_dir: Optional directory for installed extensions. Defaults to the + installed_dir instance variable. + + Returns: + True if the extension was uninstalled, False if it wasn't installed. + + Example: + >>> if uninstall("my-extension"): + ... print("Extension uninstalled") + ... else: + ... print("Extension was not installed") + """ raise NotImplementedError() def enable(self, name: str, installed_dir: Path | None = None) -> bool: + """Enable an installed extension by name.""" raise NotImplementedError() def disable(self, name: str, installed_dir: Path | None = None) -> bool: + """Disable an installed extension by name.""" raise NotImplementedError() def list_installed(self, installed_dir: Path | None = None) -> list[InfoT]: + """List all installed extensions. + + This function is self-healing: it may update the installed extensions metadata + file to remove entries whose directories were deleted, and to add entries for + extension directories that were manually copied into the installed dir. + + Args: + installed_dir: Directory for installed extensions. Defaults to the + installed_dir instance variable. + + Returns: + List of InstalledExtensionInfoBaseClass[T] for each installed extension. + + Example: + >>> for info in list_installed(): + ... print(f"{info.name} v{info.version}") + """ raise NotImplementedError() def load_installed(self, installed_dir: Path | None = None) -> list[T]: + """Load all installed extensions. + + Loads extension objects for all extensions in the installed extensions + directory. This is useful for integrating installed extensions into an agent. + + Args: + installed_dir: Directory for installed extension. + + Returns: + List of loaded extension objects. + + Example: + >>> extension = load_installed() + >>> for ext in extensions: + ... print(f"Loaded {ext}") + """ raise NotImplementedError() def get(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + """Get information about a specific installed extension. + + Args: + name: Name of the extension to look up. + installed_dir: Directory for installed extensions. + + Returns: + InstalledExtensionInfoBaseClass[T] if the extension is installed, None + otherwise. + + Example: + >>> info = get("my-extension") + >>> if info: + ... print(f"Installed from {info.source} at {info.installed_at}") + """ raise NotImplementedError() def update(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + """Update an installed extension to the latest version. + + Re-fetches the extension from its original source and reinstalls it. + + This always updates to the latest version available from the original source + (i.e., it does not preserve a pinned ref). + + Args: + name: Name of the extension to update. + installed_dir: Directory for installed extensions. Defaults to the + installed_dir instance variable. + + Returns: + Updated InstalledExtensionInfoBaseClass[T] if successful, None if extension + not installed. + + Raises: + ExtensionFetchError: If fetching the updated extension fails. + + Example: + >>> info = update("my-extension") + >>> if info: + ... print(f"Updated to v{info.version}") + """ raise NotImplementedError() From a1973a7d87f4d80fe690197e07c18172d853679f Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 09:06:25 -0600 Subject: [PATCH 14/51] utils file, simplifying interface to install manger --- .../openhands/sdk/extensions/installed.py | 32 +++++-------------- .../openhands/sdk/extensions/utils.py | 14 ++++++++ 2 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/extensions/utils.py diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index 691c91e3e0..9d7cdb9f49 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -92,18 +92,13 @@ def save_to_dir(self, installed_dir: Path) -> None: class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass](BaseModel): """Manages installed extensions.""" - installed_dir: Path = Field(description="Directory for installed extensions.") - - def _resolved_installed_dir(self, installed_dir: Path | None = None) -> Path: - """Returns installed_dir, or the default if None.""" - return installed_dir if installed_dir is not None else self.installed_dir + installation_dir: Path = Field(description="Directory for installed extensions.") def install( self, source: str, ref: str | None = None, repo_path: str | None = None, - installed_dir: Path | None = None, force: bool = False, ) -> InfoT: """Install an extension from a source. @@ -118,8 +113,6 @@ def install( - Local path (for development/testing) ref: Optional branch, tag, or commit to install. repo_path: Subdirectory path within the repository (for monorepos). - installed_dir: Optional directory for installed extensions. Defaults to the - installed_dir instance variable. force: If True, overwrite existing installation. If False, raise error if extension is already installed. @@ -139,7 +132,7 @@ def install( raise NotImplementedError() - def uninstall(self, name: str, installed_dir: Path | None = None) -> bool: + def uninstall(self, name: str) -> bool: """Uninstall an extension by name. Only extensions tracked in the installed extensions metadata file can be @@ -148,8 +141,6 @@ def uninstall(self, name: str, installed_dir: Path | None = None) -> bool: Args: name: Name of the extension to uninstall. - installed_dir: Optional directory for installed extensions. Defaults to the - installed_dir instance variable. Returns: True if the extension was uninstalled, False if it wasn't installed. @@ -162,25 +153,21 @@ def uninstall(self, name: str, installed_dir: Path | None = None) -> bool: """ raise NotImplementedError() - def enable(self, name: str, installed_dir: Path | None = None) -> bool: + def enable(self, name: str) -> bool: """Enable an installed extension by name.""" raise NotImplementedError() - def disable(self, name: str, installed_dir: Path | None = None) -> bool: + def disable(self, name: str) -> bool: """Disable an installed extension by name.""" raise NotImplementedError() - def list_installed(self, installed_dir: Path | None = None) -> list[InfoT]: + def list_installed(self) -> list[InfoT]: """List all installed extensions. This function is self-healing: it may update the installed extensions metadata file to remove entries whose directories were deleted, and to add entries for extension directories that were manually copied into the installed dir. - Args: - installed_dir: Directory for installed extensions. Defaults to the - installed_dir instance variable. - Returns: List of InstalledExtensionInfoBaseClass[T] for each installed extension. @@ -190,15 +177,12 @@ def list_installed(self, installed_dir: Path | None = None) -> list[InfoT]: """ raise NotImplementedError() - def load_installed(self, installed_dir: Path | None = None) -> list[T]: + def load_installed(self) -> list[T]: """Load all installed extensions. Loads extension objects for all extensions in the installed extensions directory. This is useful for integrating installed extensions into an agent. - Args: - installed_dir: Directory for installed extension. - Returns: List of loaded extension objects. @@ -209,7 +193,7 @@ def load_installed(self, installed_dir: Path | None = None) -> list[T]: """ raise NotImplementedError() - def get(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + def get(self, name: str) -> InfoT | None: """Get information about a specific installed extension. Args: @@ -227,7 +211,7 @@ def get(self, name: str, installed_dir: Path | None = None) -> InfoT | None: """ raise NotImplementedError() - def update(self, name: str, installed_dir: Path | None = None) -> InfoT | None: + def update(self, name: str) -> InfoT | None: """Update an installed extension to the latest version. Re-fetches the extension from its original source and reinstalls it. diff --git a/openhands-sdk/openhands/sdk/extensions/utils.py b/openhands-sdk/openhands/sdk/extensions/utils.py new file mode 100644 index 0000000000..a705b3b9c3 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/utils.py @@ -0,0 +1,14 @@ +import re +from re import Pattern + + +_EXTENSION_NAME_PATTERN: Pattern[str] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def validate_extension_name(name: str) -> None: + """Validate extension name is Claude-like kebab-case. + + This protects filesystem operations (install/uninstall) from path traversal. + """ + if not _EXTENSION_NAME_PATTERN.fullmatch(name): + raise ValueError(f"Invalid extension name. Expected kebab-case, got {name!r}.") From 6fd1e1eaa3610524b93d0ea65681c48ebbf96cbd Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 09:15:59 -0600 Subject: [PATCH 15/51] update/get --- .../openhands/sdk/extensions/installed.py | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index 9d7cdb9f49..8334ac6c2a 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field +from openhands.sdk.extensions.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -209,7 +210,18 @@ def get(self, name: str) -> InfoT | None: >>> if info: ... print(f"Installed from {info.source} at {info.installed_at}") """ - raise NotImplementedError() + validate_extension_name(name) + + metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + info = metadata.extensions.get(name) + + # Verify the extension directory still exists + if info is not None: + extension_path = self.installation_dir / name + if not extension_path.exists(): + return None + + return info def update(self, name: str) -> InfoT | None: """Update an installed extension to the latest version. @@ -236,4 +248,19 @@ def update(self, name: str) -> InfoT | None: >>> if info: ... print(f"Updated to v{info.version}") """ - raise NotImplementedError() + validate_extension_name(name) + + # Get the current installation info + current_info = self.get(name) + if current_info is None: + logger.warning(f"Extension {name} not installed") + return None + + # Re-install from the original source + logger.info(f"Updating extension {name} from {current_info.source}") + return self.install( + source=current_info.source, + ref=None, # Get the latest (don't use pinned ref) + repo_path=current_info.repo_path, + force=True, + ) From 0a3e1541a666c46cfe4c6ded0ee7065ce97a31d4 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 09:30:09 -0600 Subject: [PATCH 16/51] metadata validation --- .../openhands/sdk/extensions/installed.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installed.py index 8334ac6c2a..bcfd6b58c9 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installed.py @@ -89,6 +89,44 @@ def save_to_dir(self, installed_dir: Path) -> None: with metadata_path.open("w") as f: json.dump(self.model_dump(), f, indent=2) + def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: + """Validate tracked extensions exist on disk. + + Removes any extension with an invalid name or missing directory. + + Returns: + Tuple of (valid extensions list, whether metadata was modified). + """ + valid_extensions: list[InfoT] = [] + changed = False + + # We cannot iterate directly over the extensions because we'll be removing + # invalid extensions as we go. + for name, info in list(self.extensions.items()): + # Check the extension name + try: + validate_extension_name(name) + except ValueError as e: + logger.warning( + f"Invalid tracked extension name {name!r}, removing: {e}" + ) + del self.extensions[name] + changed = True + continue + + # Check the extension installation + extension_path = installed_dir / name + if extension_path.exists(): + valid_extensions.append(info) + else: + logger.warning( + f"Extension {name} directory missing, removing from metadata" + ) + del self.extensions[name] + changed = True + + return valid_extensions, changed + class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass](BaseModel): """Manages installed extensions.""" From 005b30abe77a9937f6bf49b410b3db4140ff8c07 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 09:34:51 -0600 Subject: [PATCH 17/51] re-org into module --- .../sdk/extensions/installation/__init__.py | 0 .../sdk/extensions/installation/info.py | 44 +++++++ .../{installed.py => installation/manager.py} | 121 +----------------- .../sdk/extensions/installation/metadata.py | 91 +++++++++++++ .../sdk/extensions/installation/utils.py | 14 ++ 5 files changed, 152 insertions(+), 118 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/__init__.py create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/info.py rename openhands-sdk/openhands/sdk/extensions/{installed.py => installation/manager.py} (59%) create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/metadata.py create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/utils.py diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py new file mode 100644 index 0000000000..65100f589e --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Self + +from pydantic import BaseModel, Field + + +class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): + """Base class for information about an installed extension. + + Linked to extensions by the installed extension metadata. + """ + + name: str = Field(description="Extension name") + version: str = Field(default="1.0.0", description="Extension version") + + enabled: bool = Field(default=True, description="Whether the extension is enabled") + + source: str = Field(description="Original source (e.g., 'github:owner/repo')") + resolved_ref: str | None = Field( + default=None, description="Resolved git commit SHA (for version pinning)" + ) + repo_path: str | None = Field( + default=None, + description="Subdirectory path within the repository (for monorepos)", + ) + + installed_at: str = Field(description="ISO 8601 timestamp of installation") + install_path: str = Field(description="Path where the extension is installed") + + @classmethod + @abstractmethod + def from_extension( + cls: type[Self], + extension: T, + source: str, + resolved_ref: str | None, + repo_path: str | None, + install_path: Path, + ) -> Self: + """Create installed extension info from a loaded skill.""" + raise NotImplementedError() diff --git a/openhands-sdk/openhands/sdk/extensions/installed.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py similarity index 59% rename from openhands-sdk/openhands/sdk/extensions/installed.py rename to openhands-sdk/openhands/sdk/extensions/installation/manager.py index bcfd6b58c9..b7858c7f20 100644 --- a/openhands-sdk/openhands/sdk/extensions/installed.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -1,133 +1,18 @@ from __future__ import annotations -import json -from abc import ABC, abstractmethod from pathlib import Path -from typing import ClassVar, Self from pydantic import BaseModel, Field -from openhands.sdk.extensions.utils import validate_extension_name +from openhands.sdk.extensions.installation.info import InstalledExtensionInfoBaseClass +from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata +from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger logger = get_logger(__name__) -class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): - """Base class for information about an installed extension. - - Linked to extensions by the installed extension metadata. - """ - - name: str = Field(description="Extension name") - version: str = Field(default="1.0.0", description="Extension version") - - enabled: bool = Field(default=True, description="Whether the extension is enabled") - - source: str = Field(description="Original source (e.g., 'github:owner/repo')") - resolved_ref: str | None = Field( - default=None, description="Resolved git commit SHA (for version pinning)" - ) - repo_path: str | None = Field( - default=None, - description="Subdirectory path within the repository (for monorepos)", - ) - - installed_at: str = Field(description="ISO 8601 timestamp of installation") - install_path: str = Field(description="Path where the extension is installed") - - @classmethod - @abstractmethod - def from_extension( - cls: type[Self], - extension: T, - source: str, - resolved_ref: str | None, - repo_path: str | None, - install_path: Path, - ) -> Self: - """Create installed extension info from a loaded skill.""" - raise NotImplementedError() - - -class InstalledExtensionMetadata[InfoT: InstalledExtensionInfoBaseClass](BaseModel): - """Metadata file for tracking installed extensions.""" - - extensions: dict[str, InfoT] = Field( - default_factory=dict, - description="Map from extension name to extension installation info", - ) - - metadata_filename: ClassVar[str] = ".installed.json" - - @classmethod - def get_metadata_path(cls, installed_dir: Path) -> Path: - """Get the metadata file path for the installed extension directory.""" - return installed_dir / cls.metadata_filename - - @classmethod - def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT]: - """Load metadata from the installed extensions directory.""" - metadata_path = cls.get_metadata_path(installed_dir) - if not metadata_path.exists(): - return cls() - - try: - with metadata_path.open() as f: - data = json.load(f) - return cls.model_validate(data) - - except Exception as e: - logger.warning(f"Failed to load installed extension metadata: {e}") - return cls() - - def save_to_dir(self, installed_dir: Path) -> None: - """Save metadata to the installed extensions directory.""" - metadata_path = self.get_metadata_path(installed_dir) - metadata_path.parent.mkdir(parents=True, exist_ok=True) - with metadata_path.open("w") as f: - json.dump(self.model_dump(), f, indent=2) - - def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: - """Validate tracked extensions exist on disk. - - Removes any extension with an invalid name or missing directory. - - Returns: - Tuple of (valid extensions list, whether metadata was modified). - """ - valid_extensions: list[InfoT] = [] - changed = False - - # We cannot iterate directly over the extensions because we'll be removing - # invalid extensions as we go. - for name, info in list(self.extensions.items()): - # Check the extension name - try: - validate_extension_name(name) - except ValueError as e: - logger.warning( - f"Invalid tracked extension name {name!r}, removing: {e}" - ) - del self.extensions[name] - changed = True - continue - - # Check the extension installation - extension_path = installed_dir / name - if extension_path.exists(): - valid_extensions.append(info) - else: - logger.warning( - f"Extension {name} directory missing, removing from metadata" - ) - del self.extensions[name] - changed = True - - return valid_extensions, changed - - class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass](BaseModel): """Manages installed extensions.""" diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py new file mode 100644 index 0000000000..24753751d5 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel, Field + +from openhands.sdk.extensions.installation.info import InstalledExtensionInfoBaseClass +from openhands.sdk.extensions.installation.utils import validate_extension_name +from openhands.sdk.logger import get_logger + + +logger = get_logger(__name__) + + +class InstalledExtensionMetadata[InfoT: InstalledExtensionInfoBaseClass](BaseModel): + """Metadata file for tracking installed extensions.""" + + extensions: dict[str, InfoT] = Field( + default_factory=dict, + description="Map from extension name to extension installation info", + ) + + metadata_filename: ClassVar[str] = ".installed.json" + + @classmethod + def get_metadata_path(cls, installed_dir: Path) -> Path: + """Get the metadata file path for the installed extension directory.""" + return installed_dir / cls.metadata_filename + + @classmethod + def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT]: + """Load metadata from the installed extensions directory.""" + metadata_path = cls.get_metadata_path(installed_dir) + if not metadata_path.exists(): + return cls() + + try: + with metadata_path.open() as f: + data = json.load(f) + return cls.model_validate(data) + + except Exception as e: + logger.warning(f"Failed to load installed extension metadata: {e}") + return cls() + + def save_to_dir(self, installed_dir: Path) -> None: + """Save metadata to the installed extensions directory.""" + metadata_path = self.get_metadata_path(installed_dir) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + with metadata_path.open("w") as f: + json.dump(self.model_dump(), f, indent=2) + + def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: + """Validate tracked extensions exist on disk. + + Removes any extension with an invalid name or missing directory. + + Returns: + Tuple of (valid extensions list, whether metadata was modified). + """ + valid_extensions: list[InfoT] = [] + changed = False + + # We cannot iterate directly over the extensions because we'll be removing + # invalid extensions as we go. + for name, info in list(self.extensions.items()): + # Check the extension name + try: + validate_extension_name(name) + except ValueError as e: + logger.warning( + f"Invalid tracked extension name {name!r}, removing: {e}" + ) + del self.extensions[name] + changed = True + continue + + # Check the extension installation + extension_path = installed_dir / name + if extension_path.exists(): + valid_extensions.append(info) + else: + logger.warning( + f"Extension {name} directory missing, removing from metadata" + ) + del self.extensions[name] + changed = True + + return valid_extensions, changed diff --git a/openhands-sdk/openhands/sdk/extensions/installation/utils.py b/openhands-sdk/openhands/sdk/extensions/installation/utils.py new file mode 100644 index 0000000000..a705b3b9c3 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installation/utils.py @@ -0,0 +1,14 @@ +import re +from re import Pattern + + +_EXTENSION_NAME_PATTERN: Pattern[str] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def validate_extension_name(name: str) -> None: + """Validate extension name is Claude-like kebab-case. + + This protects filesystem operations (install/uninstall) from path traversal. + """ + if not _EXTENSION_NAME_PATTERN.fullmatch(name): + raise ValueError(f"Invalid extension name. Expected kebab-case, got {name!r}.") From c3911bc2ab072b8e8b877e887273b79754ef65b7 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 09:35:35 -0600 Subject: [PATCH 18/51] removing unused utils --- openhands-sdk/openhands/sdk/extensions/utils.py | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 openhands-sdk/openhands/sdk/extensions/utils.py diff --git a/openhands-sdk/openhands/sdk/extensions/utils.py b/openhands-sdk/openhands/sdk/extensions/utils.py deleted file mode 100644 index a705b3b9c3..0000000000 --- a/openhands-sdk/openhands/sdk/extensions/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -import re -from re import Pattern - - -_EXTENSION_NAME_PATTERN: Pattern[str] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def validate_extension_name(name: str) -> None: - """Validate extension name is Claude-like kebab-case. - - This protects filesystem operations (install/uninstall) from path traversal. - """ - if not _EXTENSION_NAME_PATTERN.fullmatch(name): - raise ValueError(f"Invalid extension name. Expected kebab-case, got {name!r}.") From f83540ae5e0be494dc354b246988281b95434fa6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:01:25 -0600 Subject: [PATCH 19/51] genericized metadata --- .../sdk/extensions/installation/metadata.py | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 24753751d5..40fd34fd3b 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path -from typing import ClassVar +from typing import ClassVar, Protocol from pydantic import BaseModel, Field @@ -14,6 +16,10 @@ logger = get_logger(__name__) +class ExtensionProtocol(Protocol): + name: str + + class InstalledExtensionMetadata[InfoT: InstalledExtensionInfoBaseClass](BaseModel): """Metadata file for tracking installed extensions.""" @@ -89,3 +95,74 @@ def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: changed = True return valid_extensions, changed + + def discover_untracked[T: ExtensionProtocol]( + self, + installed_dir: Path, + extension_loader: Callable[[Path], T], + info_extractor: Callable[[T], InfoT], + ) -> tuple[list[InfoT], bool]: + """Discover extension directories not tracked by the metadata. + + Returns: + Tuple of (discovered extensions list, whether metadata was modified). + """ + discovered: list[InfoT] = [] + changed = False + + for item in installed_dir.iterdir(): + # Focus only on non-hidden directories + if not item.is_dir() or item.name.startswith("."): + continue + + # Ignore already-tracked extensions + if item.name in self.extensions: + continue + + # Ignore directories with the wrong naming scheme + try: + validate_extension_name(item.name) + except ValueError: + logger.debug(f"Skipping directory with invalid extension name: {item}") + + # Try to load the directory as the indicated extension + try: + extension = extension_loader(item) + except Exception as e: + logger.debug(f"Skipping directory {item}: {e}") + continue + + if extension.name != item.name: + logger.warning( + "Skipping extension directory because manifest name doesn't match " + f"directory name: dir={item.name!r}, manifest={extension.name!r}" + ) + continue + + info = info_extractor(extension) + info.source = "local" + info.installed_at = datetime.now(UTC).isoformat() + info.install_path = str(item) + + discovered.append(info) + self.extensions[item.name] = info + changed = True + logger.info(f"Discovered untracked extension: {extension.name}") + + return discovered, changed + + def sync_installed[T: ExtensionProtocol]( + self, + installed_dir: Path, + extension_loader: Callable[[Path], T], + info_extractor: Callable[[T], InfoT], + ) -> list[InfoT]: + valid_extensions, tracked_changed = self.validate_tracked(installed_dir) + discovered, discovered_changed = self.discover_untracked( + installed_dir, extension_loader, info_extractor + ) + + if tracked_changed or discovered_changed: + self.save_to_dir(installed_dir) + + return valid_extensions + discovered From c48c7cf2429cbd4c3a0d21d2ad5120a7405338f6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:12:15 -0600 Subject: [PATCH 20/51] interface instead of badly linked generics --- .../sdk/extensions/installation/info.py | 21 +-------- .../sdk/extensions/installation/interface.py | 19 ++++++++ .../sdk/extensions/installation/manager.py | 22 ++++++---- .../sdk/extensions/installation/metadata.py | 44 +++++++++---------- 4 files changed, 55 insertions(+), 51 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/interface.py diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index 65100f589e..b4515bdf5a 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -1,14 +1,10 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Self - from pydantic import BaseModel, Field -class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): - """Base class for information about an installed extension. +class InstalledExtensionInfo(BaseModel): + """Information about an installed extension. Linked to extensions by the installed extension metadata. """ @@ -29,16 +25,3 @@ class InstalledExtensionInfoBaseClass[T](ABC, BaseModel): installed_at: str = Field(description="ISO 8601 timestamp of installation") install_path: str = Field(description="Path where the extension is installed") - - @classmethod - @abstractmethod - def from_extension( - cls: type[Self], - extension: T, - source: str, - resolved_ref: str | None, - repo_path: str | None, - install_path: Path, - ) -> Self: - """Create installed extension info from a loaded skill.""" - raise NotImplementedError() diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py new file mode 100644 index 0000000000..16bbd62594 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Protocol + +from openhands.sdk.extensions.installation.info import InstalledExtensionInfo + + +class InstallableExtensionProtocol(Protocol): + name: str + + +class InstallableExtensionInterface[T: InstallableExtensionProtocol](ABC): + @staticmethod + @abstractmethod + def load_from_dir(extension_dir: Path) -> T: ... + + @staticmethod + @abstractmethod + def installation_info(extension: T) -> InstalledExtensionInfo: ... diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index b7858c7f20..7a594d3e9c 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -1,10 +1,12 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path -from pydantic import BaseModel, Field - -from openhands.sdk.extensions.installation.info import InstalledExtensionInfoBaseClass +from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.interface import ( + InstallableExtensionInterface, +) from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -13,10 +15,12 @@ logger = get_logger(__name__) -class InstalledExtensionManager[T, InfoT: InstalledExtensionInfoBaseClass](BaseModel): +@dataclass +class InstalledExtensionManager[T]: """Manages installed extensions.""" - installation_dir: Path = Field(description="Directory for installed extensions.") + installation_dir: Path + installation_interface: InstallableExtensionInterface def install( self, @@ -24,7 +28,7 @@ def install( ref: str | None = None, repo_path: str | None = None, force: bool = False, - ) -> InfoT: + ) -> InstalledExtensionInfo: """Install an extension from a source. Fetches the extensionFrom the source, copies it to the installed extensions @@ -85,7 +89,7 @@ def disable(self, name: str) -> bool: """Disable an installed extension by name.""" raise NotImplementedError() - def list_installed(self) -> list[InfoT]: + def list_installed(self) -> list[InstalledExtensionInfo]: """List all installed extensions. This function is self-healing: it may update the installed extensions metadata @@ -117,7 +121,7 @@ def load_installed(self) -> list[T]: """ raise NotImplementedError() - def get(self, name: str) -> InfoT | None: + def get(self, name: str) -> InstalledExtensionInfo | None: """Get information about a specific installed extension. Args: @@ -146,7 +150,7 @@ def get(self, name: str) -> InfoT | None: return info - def update(self, name: str) -> InfoT | None: + def update(self, name: str) -> InstalledExtensionInfo | None: """Update an installed extension to the latest version. Re-fetches the extension from its original source and reinstalls it. diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 40fd34fd3b..1a833f1f81 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -1,14 +1,16 @@ from __future__ import annotations import json -from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import ClassVar, Protocol from pydantic import BaseModel, Field -from openhands.sdk.extensions.installation.info import InstalledExtensionInfoBaseClass +from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.interface import ( + InstallableExtensionInterface, +) from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -20,10 +22,10 @@ class ExtensionProtocol(Protocol): name: str -class InstalledExtensionMetadata[InfoT: InstalledExtensionInfoBaseClass](BaseModel): +class InstalledExtensionMetadata(BaseModel): """Metadata file for tracking installed extensions.""" - extensions: dict[str, InfoT] = Field( + extensions: dict[str, InstalledExtensionInfo] = Field( default_factory=dict, description="Map from extension name to extension installation info", ) @@ -36,7 +38,7 @@ def get_metadata_path(cls, installed_dir: Path) -> Path: return installed_dir / cls.metadata_filename @classmethod - def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata[InfoT]: + def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata: """Load metadata from the installed extensions directory.""" metadata_path = cls.get_metadata_path(installed_dir) if not metadata_path.exists(): @@ -58,7 +60,9 @@ def save_to_dir(self, installed_dir: Path) -> None: with metadata_path.open("w") as f: json.dump(self.model_dump(), f, indent=2) - def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: + def validate_tracked( + self, installed_dir: Path + ) -> tuple[list[InstalledExtensionInfo], bool]: """Validate tracked extensions exist on disk. Removes any extension with an invalid name or missing directory. @@ -66,7 +70,7 @@ def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: Returns: Tuple of (valid extensions list, whether metadata was modified). """ - valid_extensions: list[InfoT] = [] + valid_extensions: list[InstalledExtensionInfo] = [] changed = False # We cannot iterate directly over the extensions because we'll be removing @@ -96,18 +100,15 @@ def validate_tracked(self, installed_dir: Path) -> tuple[list[InfoT], bool]: return valid_extensions, changed - def discover_untracked[T: ExtensionProtocol]( - self, - installed_dir: Path, - extension_loader: Callable[[Path], T], - info_extractor: Callable[[T], InfoT], - ) -> tuple[list[InfoT], bool]: + def discover_untracked( + self, installed_dir: Path, installation_interface: InstallableExtensionInterface + ) -> tuple[list[InstalledExtensionInfo], bool]: """Discover extension directories not tracked by the metadata. Returns: Tuple of (discovered extensions list, whether metadata was modified). """ - discovered: list[InfoT] = [] + discovered: list[InstalledExtensionInfo] = [] changed = False for item in installed_dir.iterdir(): @@ -127,7 +128,7 @@ def discover_untracked[T: ExtensionProtocol]( # Try to load the directory as the indicated extension try: - extension = extension_loader(item) + extension = installation_interface.load_from_dir(item) except Exception as e: logger.debug(f"Skipping directory {item}: {e}") continue @@ -139,7 +140,7 @@ def discover_untracked[T: ExtensionProtocol]( ) continue - info = info_extractor(extension) + info = installation_interface.installation_info(extension) info.source = "local" info.installed_at = datetime.now(UTC).isoformat() info.install_path = str(item) @@ -151,15 +152,12 @@ def discover_untracked[T: ExtensionProtocol]( return discovered, changed - def sync_installed[T: ExtensionProtocol]( - self, - installed_dir: Path, - extension_loader: Callable[[Path], T], - info_extractor: Callable[[T], InfoT], - ) -> list[InfoT]: + def sync_installed( + self, installed_dir: Path, installation_interface: InstallableExtensionInterface + ) -> list[InstalledExtensionInfo]: valid_extensions, tracked_changed = self.validate_tracked(installed_dir) discovered, discovered_changed = self.discover_untracked( - installed_dir, extension_loader, info_extractor + installed_dir, installation_interface ) if tracked_changed or discovered_changed: From cb30b9201c859c06f16d345dca8a66ced7f1ecc6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:14:12 -0600 Subject: [PATCH 21/51] refining generic in manager --- .../openhands/sdk/extensions/installation/manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 7a594d3e9c..fccfda5622 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -6,6 +6,7 @@ from openhands.sdk.extensions.installation.info import InstalledExtensionInfo from openhands.sdk.extensions.installation.interface import ( InstallableExtensionInterface, + InstallableExtensionProtocol, ) from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata from openhands.sdk.extensions.installation.utils import validate_extension_name @@ -16,11 +17,11 @@ @dataclass -class InstalledExtensionManager[T]: +class InstalledExtensionManager[T: InstallableExtensionProtocol]: """Manages installed extensions.""" installation_dir: Path - installation_interface: InstallableExtensionInterface + installation_interface: InstallableExtensionInterface[T] def install( self, From c9ff175612cca903e7e382d11fee126346337b65 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:20:45 -0600 Subject: [PATCH 22/51] list/load --- .../sdk/extensions/installation/manager.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index fccfda5622..6d696a80db 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -104,7 +104,14 @@ def list_installed(self) -> list[InstalledExtensionInfo]: >>> for info in list_installed(): ... print(f"{info.name} v{info.version}") """ - raise NotImplementedError() + if not self.installation_dir.exists(): + return [] + + metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + info = metadata.sync_installed( + self.installation_dir, self.installation_interface + ) + return info def load_installed(self) -> list[T]: """Load all installed extensions. @@ -120,7 +127,21 @@ def load_installed(self) -> list[T]: >>> for ext in extensions: ... print(f"Loaded {ext}") """ - raise NotImplementedError() + if not self.installation_dir.exists(): + return [] + + extensions: list[T] = [] + + for info in self.list_installed(): + if not info.enabled: + continue + + extension_path = self.installation_dir / info.name + if extension_path.exists(): + extension = self.installation_interface.load_from_dir(extension_path) + extensions.append(extension) + + return extensions def get(self, name: str) -> InstalledExtensionInfo | None: """Get information about a specific installed extension. From 509dd24dc5df7d7a9046e43a20f9f93daab75907 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:28:05 -0600 Subject: [PATCH 23/51] enable/disable --- .../sdk/extensions/installation/manager.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 6d696a80db..7ed068b307 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -82,13 +82,52 @@ def uninstall(self, name: str) -> bool: """ raise NotImplementedError() + def _set_enabled( + self, + name: str, + enabled: bool, + ) -> bool: + validate_extension_name(name) + + if not self.installation_dir.exists(): + logger.warning( + f"Installation directory does not exist: {self.installation_dir}" + ) + return False + + metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata.sync_installed(self.installation_dir, self.installation_interface) + + info = metadata.extensions.get(name) + if info is None: + logger.warning(f"Extension '{name}' is not installed") + return False + + extension_path = self.installation_dir / name + if not extension_path.exists(): + logger.warning( + f"Extension '{name}' was tracked but {extension_path} is missing" + ) + return False + + if info.enabled == enabled: + return True + + info.enabled = enabled + metadata.extensions[name] = info + metadata.save_to_dir(self.installation_dir) + + state = "enabled" if enabled else "disabled" + logger.info(f"Successfully {state} extension '{name}'") + return True + def enable(self, name: str) -> bool: """Enable an installed extension by name.""" - raise NotImplementedError() + return self._set_enabled(name, True) def disable(self, name: str) -> bool: """Disable an installed extension by name.""" - raise NotImplementedError() + return self._set_enabled(name, False) def list_installed(self) -> list[InstalledExtensionInfo]: """List all installed extensions. From 77860880aecd7e90a4eb8369b4310f2ea63341e6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:39:56 -0600 Subject: [PATCH 24/51] install/uninstall --- .../sdk/extensions/installation/manager.py | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 7ed068b307..fa0f6cafe6 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -1,8 +1,10 @@ from __future__ import annotations +import shutil from dataclasses import dataclass from pathlib import Path +from openhands.sdk.extensions.fetch import fetch_with_resolution from openhands.sdk.extensions.installation.info import InstalledExtensionInfo from openhands.sdk.extensions.installation.interface import ( InstallableExtensionInterface, @@ -58,8 +60,57 @@ def install( >>> info = install("github:owner/my-extension", ref="v1.0.0") >>> print(f"Installed {info.name} from {info.source}") """ + # Fetch the extension (downloads to cache if remote) + logger.info(f"Fetching extension from {source}") + fetched_path, resolved_ref = fetch_with_resolution( + source=source, + cache_dir=self.installation_dir / ".cache", + ref=ref, + repo_path=repo_path, + update=True, + ) + + # Load the extension to get its metadata + extension = self.installation_interface.load_from_dir(fetched_path) + validate_extension_name(extension.name) + + # Check if already installed + install_path = self.installation_dir / extension.name + if install_path.exists() and not force: + raise FileExistsError( + f"Extension '{extension.name}' is already installed at {install_path}. " + f"Use force=True to overwrite." + ) - raise NotImplementedError() + # Remove existing installation if force=True + if install_path.exists(): + logger.info(f"Removing existing installation of '{extension.name}'") + shutil.rmtree(install_path) + + # Copy plugin to installed directory + logger.info(f"Installing extension '{extension.name}' to {install_path}") + self.installation_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(fetched_path, install_path) + + # Create installation info + info = self.installation_interface.installation_info(extension) + info.source = source + info.resolved_ref = resolved_ref + info.repo_path = repo_path + info.install_path = str(install_path) # TODO: convert info field to path + + # Update metadata + metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + existing_info = metadata.extensions.get(extension.name) + if existing_info is not None: + info.enabled = existing_info.enabled + metadata.extensions[extension.name] = info + metadata.save_to_dir(self.installation_dir) + + logger.info( + f"Successfully installed extension '{extension.name}' v{info.version}" + ) + return info def uninstall(self, name: str) -> bool: """Uninstall an extension by name. @@ -80,7 +131,27 @@ def uninstall(self, name: str) -> bool: ... else: ... print("Extension was not installed") """ - raise NotImplementedError() + validate_extension_name(name) + + metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + if name not in metadata.extensions: + logger.warning(f"Plugin '{name}' is not installed") + return False + + extension_path = self.installation_dir / name + if extension_path.exists(): + logger.info(f"Uninstalling extension '{name}' from {extension_path}") + shutil.rmtree(extension_path) + else: + logger.warning( + f"Extension '{name}' was tracked but {extension_path} is missing" + ) + + del metadata.extensions[name] + metadata.save_to_dir(self.installation_dir) + + logger.info(f"Successfully uninstalled extension '{name}'") + return True def _set_enabled( self, From 1d52b8936589024b2178bb52a70e5ee302461bbc Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:44:41 -0600 Subject: [PATCH 25/51] minor todos --- .../openhands/sdk/extensions/installation/interface.py | 4 +++- .../openhands/sdk/extensions/installation/manager.py | 3 ++- .../openhands/sdk/extensions/installation/metadata.py | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 16bbd62594..09d7dfc4b1 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -16,4 +16,6 @@ def load_from_dir(extension_dir: Path) -> T: ... @staticmethod @abstractmethod - def installation_info(extension: T) -> InstalledExtensionInfo: ... + def installation_info(extension: T) -> InstalledExtensionInfo: + ... + # TODO: there's no way this signature is all we need diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index fa0f6cafe6..4c383ec539 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -64,7 +64,8 @@ def install( logger.info(f"Fetching extension from {source}") fetched_path, resolved_ref = fetch_with_resolution( source=source, - cache_dir=self.installation_dir / ".cache", + cache_dir=self.installation_dir + / ".cache", # TODO: check this cache value works ref=ref, repo_path=repo_path, update=True, diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 1a833f1f81..e803af3ed1 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -155,6 +155,8 @@ def discover_untracked( def sync_installed( self, installed_dir: Path, installation_interface: InstallableExtensionInterface ) -> list[InstalledExtensionInfo]: + # TODO: Doc-string + # TODO: add context manager for this class that loads, syncs, then forces a save valid_extensions, tracked_changed = self.validate_tracked(installed_dir) discovered, discovered_changed = self.discover_untracked( installed_dir, installation_interface From 3db00dc834811714d2f2da2904c81249263c5aa7 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 10:50:36 -0600 Subject: [PATCH 26/51] readme and init file, initial --- .../sdk/extensions/installation/README.md | 11 +++++++++++ .../sdk/extensions/installation/__init__.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 openhands-sdk/openhands/sdk/extensions/installation/README.md diff --git a/openhands-sdk/openhands/sdk/extensions/installation/README.md b/openhands-sdk/openhands/sdk/extensions/installation/README.md new file mode 100644 index 0000000000..64243f83e5 --- /dev/null +++ b/openhands-sdk/openhands/sdk/extensions/installation/README.md @@ -0,0 +1,11 @@ +# Installation + +This module provides utilities for installing, tracking, and loading extensions. + +## How to Use + +The main entry point is `InstalledExtensionManager`. When constructing the manager, we must provide an installation directory and an installation interface. The latter provides methods to load an extension from disk and to generate installation metadata. + +EXAMPLE SOURCE GOES HERE + +Once the `InstalledExtensionManager` is instantiated, you can install/uninstall extensions, enable/disable installed extensions, and get information about the currently-managed extensions. diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index e69de29bb2..63dd160c77 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -0,0 +1,16 @@ +from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.interface import ( + InstallableExtensionInterface, + InstallableExtensionProtocol, +) +from openhands.sdk.extensions.installation.manager import InstalledExtensionManager +from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata + + +__all__ = [ + "InstalledExtensionInfo", + "InstallableExtensionInterface", + "InstallableExtensionProtocol", + "InstalledExtensionManager", + "InstalledExtensionMetadata", +] From add5d69080c9f420759c7525cede67dc9d3ecd1c Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 11:48:50 -0600 Subject: [PATCH 27/51] initial utils tests --- .../installation/test_installation_utils.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/sdk/extensions/installation/test_installation_utils.py diff --git a/tests/sdk/extensions/installation/test_installation_utils.py b/tests/sdk/extensions/installation/test_installation_utils.py new file mode 100644 index 0000000000..711d2fffe1 --- /dev/null +++ b/tests/sdk/extensions/installation/test_installation_utils.py @@ -0,0 +1,22 @@ +import pytest + +from openhands.sdk.extensions.installation.utils import validate_extension_name + + +@pytest.mark.parametrize( + "input, valid", + [ + ("", False), + ("kebab-case", True), + ("simple", True), + ("CamelCase", False), + ("---", False), + ], +) +def test_validate_extension_name(input: str, valid: bool): + """Tests that validate_extension_name captures kebab-case.""" + if valid: + assert validate_extension_name(input) is None + else: + with pytest.raises(ValueError): + validate_extension_name(input) From a813f199fe78cd659727b9472ad2a26810cf4ec7 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 11:49:54 -0600 Subject: [PATCH 28/51] rename installation info --- .../sdk/extensions/installation/__init__.py | 4 ++-- .../openhands/sdk/extensions/installation/info.py | 2 +- .../sdk/extensions/installation/interface.py | 4 ++-- .../sdk/extensions/installation/manager.py | 10 +++++----- .../sdk/extensions/installation/metadata.py | 14 +++++++------- tests/sdk/extensions/installation/__init__.py | 0 .../installation/test_installation_info.py | 0 .../installation/test_installation_manager.py | 0 .../installation/test_installation_metadata.py | 0 9 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 tests/sdk/extensions/installation/__init__.py create mode 100644 tests/sdk/extensions/installation/test_installation_info.py create mode 100644 tests/sdk/extensions/installation/test_installation_manager.py create mode 100644 tests/sdk/extensions/installation/test_installation_metadata.py diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index 63dd160c77..0635e0dbd4 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -1,4 +1,4 @@ -from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( InstallableExtensionInterface, InstallableExtensionProtocol, @@ -8,7 +8,7 @@ __all__ = [ - "InstalledExtensionInfo", + "InstallationInfo", "InstallableExtensionInterface", "InstallableExtensionProtocol", "InstalledExtensionManager", diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index b4515bdf5a..0acfa17718 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -class InstalledExtensionInfo(BaseModel): +class InstallationInfo(BaseModel): """Information about an installed extension. Linked to extensions by the installed extension metadata. diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 09d7dfc4b1..48e6780446 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Protocol -from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.info import InstallationInfo class InstallableExtensionProtocol(Protocol): @@ -16,6 +16,6 @@ def load_from_dir(extension_dir: Path) -> T: ... @staticmethod @abstractmethod - def installation_info(extension: T) -> InstalledExtensionInfo: + def installation_info(extension: T) -> InstallationInfo: ... # TODO: there's no way this signature is all we need diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 4c383ec539..06a0d68300 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -5,7 +5,7 @@ from pathlib import Path from openhands.sdk.extensions.fetch import fetch_with_resolution -from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( InstallableExtensionInterface, InstallableExtensionProtocol, @@ -31,7 +31,7 @@ def install( ref: str | None = None, repo_path: str | None = None, force: bool = False, - ) -> InstalledExtensionInfo: + ) -> InstallationInfo: """Install an extension from a source. Fetches the extensionFrom the source, copies it to the installed extensions @@ -201,7 +201,7 @@ def disable(self, name: str) -> bool: """Disable an installed extension by name.""" return self._set_enabled(name, False) - def list_installed(self) -> list[InstalledExtensionInfo]: + def list_installed(self) -> list[InstallationInfo]: """List all installed extensions. This function is self-healing: it may update the installed extensions metadata @@ -254,7 +254,7 @@ def load_installed(self) -> list[T]: return extensions - def get(self, name: str) -> InstalledExtensionInfo | None: + def get(self, name: str) -> InstallationInfo | None: """Get information about a specific installed extension. Args: @@ -283,7 +283,7 @@ def get(self, name: str) -> InstalledExtensionInfo | None: return info - def update(self, name: str) -> InstalledExtensionInfo | None: + def update(self, name: str) -> InstallationInfo | None: """Update an installed extension to the latest version. Re-fetches the extension from its original source and reinstalls it. diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index e803af3ed1..0744e802e0 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field -from openhands.sdk.extensions.installation.info import InstalledExtensionInfo +from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( InstallableExtensionInterface, ) @@ -25,7 +25,7 @@ class ExtensionProtocol(Protocol): class InstalledExtensionMetadata(BaseModel): """Metadata file for tracking installed extensions.""" - extensions: dict[str, InstalledExtensionInfo] = Field( + extensions: dict[str, InstallationInfo] = Field( default_factory=dict, description="Map from extension name to extension installation info", ) @@ -62,7 +62,7 @@ def save_to_dir(self, installed_dir: Path) -> None: def validate_tracked( self, installed_dir: Path - ) -> tuple[list[InstalledExtensionInfo], bool]: + ) -> tuple[list[InstallationInfo], bool]: """Validate tracked extensions exist on disk. Removes any extension with an invalid name or missing directory. @@ -70,7 +70,7 @@ def validate_tracked( Returns: Tuple of (valid extensions list, whether metadata was modified). """ - valid_extensions: list[InstalledExtensionInfo] = [] + valid_extensions: list[InstallationInfo] = [] changed = False # We cannot iterate directly over the extensions because we'll be removing @@ -102,13 +102,13 @@ def validate_tracked( def discover_untracked( self, installed_dir: Path, installation_interface: InstallableExtensionInterface - ) -> tuple[list[InstalledExtensionInfo], bool]: + ) -> tuple[list[InstallationInfo], bool]: """Discover extension directories not tracked by the metadata. Returns: Tuple of (discovered extensions list, whether metadata was modified). """ - discovered: list[InstalledExtensionInfo] = [] + discovered: list[InstallationInfo] = [] changed = False for item in installed_dir.iterdir(): @@ -154,7 +154,7 @@ def discover_untracked( def sync_installed( self, installed_dir: Path, installation_interface: InstallableExtensionInterface - ) -> list[InstalledExtensionInfo]: + ) -> list[InstallationInfo]: # TODO: Doc-string # TODO: add context manager for this class that loads, syncs, then forces a save valid_extensions, tracked_changed = self.validate_tracked(installed_dir) diff --git a/tests/sdk/extensions/installation/__init__.py b/tests/sdk/extensions/installation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/sdk/extensions/installation/test_installation_info.py b/tests/sdk/extensions/installation/test_installation_info.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/sdk/extensions/installation/test_installation_manager.py b/tests/sdk/extensions/installation/test_installation_manager.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py new file mode 100644 index 0000000000..e69de29bb2 From f398ea9753c9bc52650289e70d99cc45f830c6ca Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 11:55:27 -0600 Subject: [PATCH 29/51] rename extension protocol and installation interface --- .../openhands/sdk/extensions/installation/__init__.py | 8 ++++---- .../openhands/sdk/extensions/installation/interface.py | 6 ++++-- .../openhands/sdk/extensions/installation/manager.py | 8 ++++---- .../openhands/sdk/extensions/installation/metadata.py | 6 +++--- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index 0635e0dbd4..41d967aebb 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -1,7 +1,7 @@ from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( - InstallableExtensionInterface, - InstallableExtensionProtocol, + ExtensionProtocol, + InstallationInterface, ) from openhands.sdk.extensions.installation.manager import InstalledExtensionManager from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata @@ -9,8 +9,8 @@ __all__ = [ "InstallationInfo", - "InstallableExtensionInterface", - "InstallableExtensionProtocol", + "InstallationInterface", + "ExtensionProtocol", "InstalledExtensionManager", "InstalledExtensionMetadata", ] diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 48e6780446..8bae0ad1b8 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -5,11 +5,13 @@ from openhands.sdk.extensions.installation.info import InstallationInfo -class InstallableExtensionProtocol(Protocol): +class ExtensionProtocol(Protocol): name: str + version: str + description: str -class InstallableExtensionInterface[T: InstallableExtensionProtocol](ABC): +class InstallationInterface[T: ExtensionProtocol](ABC): @staticmethod @abstractmethod def load_from_dir(extension_dir: Path) -> T: ... diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 06a0d68300..4b54047a4f 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -7,8 +7,8 @@ from openhands.sdk.extensions.fetch import fetch_with_resolution from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( - InstallableExtensionInterface, - InstallableExtensionProtocol, + ExtensionProtocol, + InstallationInterface, ) from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata from openhands.sdk.extensions.installation.utils import validate_extension_name @@ -19,11 +19,11 @@ @dataclass -class InstalledExtensionManager[T: InstallableExtensionProtocol]: +class InstalledExtensionManager[T: ExtensionProtocol]: """Manages installed extensions.""" installation_dir: Path - installation_interface: InstallableExtensionInterface[T] + installation_interface: InstallationInterface[T] def install( self, diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 0744e802e0..c0c025b4d6 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -9,7 +9,7 @@ from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( - InstallableExtensionInterface, + InstallationInterface, ) from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -101,7 +101,7 @@ def validate_tracked( return valid_extensions, changed def discover_untracked( - self, installed_dir: Path, installation_interface: InstallableExtensionInterface + self, installed_dir: Path, installation_interface: InstallationInterface ) -> tuple[list[InstallationInfo], bool]: """Discover extension directories not tracked by the metadata. @@ -153,7 +153,7 @@ def discover_untracked( return discovered, changed def sync_installed( - self, installed_dir: Path, installation_interface: InstallableExtensionInterface + self, installed_dir: Path, installation_interface: InstallationInterface ) -> list[InstallationInfo]: # TODO: Doc-string # TODO: add context manager for this class that loads, syncs, then forces a save From 8ef6746c831da14444e5929f44c27293c618a1db Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 12:09:08 -0600 Subject: [PATCH 30/51] better installation info construction --- .../sdk/extensions/installation/info.py | 37 ++++++++++++++++++- .../sdk/extensions/installation/interface.py | 14 +++---- .../sdk/extensions/installation/manager.py | 12 +++--- .../sdk/extensions/installation/metadata.py | 7 ++-- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index 0acfa17718..d190886930 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -1,7 +1,12 @@ from __future__ import annotations +from datetime import UTC, datetime +from pathlib import Path + from pydantic import BaseModel, Field +from openhands.sdk.extensions.installation.interface import ExtensionProtocol + class InstallationInfo(BaseModel): """Information about an installed extension. @@ -11,6 +16,7 @@ class InstallationInfo(BaseModel): name: str = Field(description="Extension name") version: str = Field(default="1.0.0", description="Extension version") + description: str = Field(default="", description="Extension description") enabled: bool = Field(default=True, description="Whether the extension is enabled") @@ -23,5 +29,32 @@ class InstallationInfo(BaseModel): description="Subdirectory path within the repository (for monorepos)", ) - installed_at: str = Field(description="ISO 8601 timestamp of installation") - install_path: str = Field(description="Path where the extension is installed") + installed_at: str = Field( + default_factory=lambda: datetime.now(UTC).isoformat(), + description="ISO 8601 timestamp of installation", + ) + install_path: Path = Field(description="Path where the extension is installed") + + @staticmethod + def from_extension( + extension: ExtensionProtocol, + source: str, + install_path: Path, + resolved_ref: str | None = None, + repo_path: str | None = None, + ) -> InstallationInfo: + """Construct an InstallationInfo object from an extension, plus relevant + installation information. + + Args: + extension: Any installable extension object. + """ + return InstallationInfo( + name=extension.name, + version=extension.version, + description=extension.description, + source=source, + resolved_ref=resolved_ref, + repo_path=repo_path, + install_path=install_path, + ) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 8bae0ad1b8..6ff64ad85e 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -2,10 +2,14 @@ from pathlib import Path from typing import Protocol -from openhands.sdk.extensions.installation.info import InstallationInfo - class ExtensionProtocol(Protocol): + """Protocol defining the expected fields needed for an extension. + + These fields are necessary to construct the InstallationInfo object fully, and are + usually guaranteed by the relevant Anthropic standards. + """ + name: str version: str description: str @@ -15,9 +19,3 @@ class InstallationInterface[T: ExtensionProtocol](ABC): @staticmethod @abstractmethod def load_from_dir(extension_dir: Path) -> T: ... - - @staticmethod - @abstractmethod - def installation_info(extension: T) -> InstallationInfo: - ... - # TODO: there's no way this signature is all we need diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 4b54047a4f..ffffaaf6c4 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -94,11 +94,13 @@ def install( shutil.copytree(fetched_path, install_path) # Create installation info - info = self.installation_interface.installation_info(extension) - info.source = source - info.resolved_ref = resolved_ref - info.repo_path = repo_path - info.install_path = str(install_path) # TODO: convert info field to path + info = InstallationInfo.from_extension( + extension, + source=source, + install_path=install_path, + resolved_ref=resolved_ref, + repo_path=repo_path, + ) # Update metadata metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index c0c025b4d6..8e4b6c057d 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -140,10 +140,9 @@ def discover_untracked( ) continue - info = installation_interface.installation_info(extension) - info.source = "local" - info.installed_at = datetime.now(UTC).isoformat() - info.install_path = str(item) + info = InstallationInfo.from_extension( + extension, source="local", install_path=item + ) discovered.append(info) self.extensions[item.name] = info From 594e13c55822f93af6860ee0078147415dc4c6c8 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 12:11:43 -0600 Subject: [PATCH 31/51] minor import fixes --- .../openhands/sdk/extensions/installation/metadata.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 8e4b6c057d..1f2439b417 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -1,9 +1,8 @@ from __future__ import annotations import json -from datetime import UTC, datetime from pathlib import Path -from typing import ClassVar, Protocol +from typing import ClassVar from pydantic import BaseModel, Field @@ -18,10 +17,6 @@ logger = get_logger(__name__) -class ExtensionProtocol(Protocol): - name: str - - class InstalledExtensionMetadata(BaseModel): """Metadata file for tracking installed extensions.""" From 53498f422244591e2f283813372d0894c74232fd Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 12:23:57 -0600 Subject: [PATCH 32/51] installation info tests --- .../installation/test_installation_info.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/sdk/extensions/installation/test_installation_info.py b/tests/sdk/extensions/installation/test_installation_info.py index e69de29bb2..c334c51e3c 100644 --- a/tests/sdk/extensions/installation/test_installation_info.py +++ b/tests/sdk/extensions/installation/test_installation_info.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from openhands.sdk.extensions.installation import InstallationInfo + + +@dataclass +class MockExtension: + name: str + version: str + description: str + + +def test_installation_info_from_extension(): + """Test InstallationInfo construction from extensions populates as expected.""" + extension = MockExtension( + name="name", version="0.1.2", description="Test extension please ignore" + ) + source = "local" + install_path = Path.cwd() + info = InstallationInfo.from_extension(extension, source, install_path) + + assert info.name == extension.name + assert info.version == extension.version + assert info.description == extension.description + + assert info.source == source + assert info.install_path == install_path + + assert info.enabled + + assert info.resolved_ref is None + assert info.repo_path is None + + assert datetime.fromisoformat(info.installed_at) From 1032c35df6e1a2227be3ae036b225b522873523d Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 12:40:04 -0600 Subject: [PATCH 33/51] metadata rename and tests --- .../sdk/extensions/installation/__init__.py | 4 +- .../sdk/extensions/installation/manager.py | 12 +++--- .../sdk/extensions/installation/metadata.py | 8 ++-- .../test_installation_metadata.py | 42 +++++++++++++++++++ 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index 41d967aebb..2921e50d2d 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -4,7 +4,7 @@ InstallationInterface, ) from openhands.sdk.extensions.installation.manager import InstalledExtensionManager -from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata +from openhands.sdk.extensions.installation.metadata import InstallationMetadata __all__ = [ @@ -12,5 +12,5 @@ "InstallationInterface", "ExtensionProtocol", "InstalledExtensionManager", - "InstalledExtensionMetadata", + "InstallationMetadata", ] diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index ffffaaf6c4..cef9326df9 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -10,7 +10,7 @@ ExtensionProtocol, InstallationInterface, ) -from openhands.sdk.extensions.installation.metadata import InstalledExtensionMetadata +from openhands.sdk.extensions.installation.metadata import InstallationMetadata from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -103,7 +103,7 @@ def install( ) # Update metadata - metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata = InstallationMetadata.load_from_dir(self.installation_dir) existing_info = metadata.extensions.get(extension.name) if existing_info is not None: info.enabled = existing_info.enabled @@ -136,7 +136,7 @@ def uninstall(self, name: str) -> bool: """ validate_extension_name(name) - metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata = InstallationMetadata.load_from_dir(self.installation_dir) if name not in metadata.extensions: logger.warning(f"Plugin '{name}' is not installed") return False @@ -169,7 +169,7 @@ def _set_enabled( ) return False - metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata = InstallationMetadata.load_from_dir(self.installation_dir) metadata.sync_installed(self.installation_dir, self.installation_interface) info = metadata.extensions.get(name) @@ -220,7 +220,7 @@ def list_installed(self) -> list[InstallationInfo]: if not self.installation_dir.exists(): return [] - metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata = InstallationMetadata.load_from_dir(self.installation_dir) info = metadata.sync_installed( self.installation_dir, self.installation_interface ) @@ -274,7 +274,7 @@ def get(self, name: str) -> InstallationInfo | None: """ validate_extension_name(name) - metadata = InstalledExtensionMetadata.load_from_dir(self.installation_dir) + metadata = InstallationMetadata.load_from_dir(self.installation_dir) info = metadata.extensions.get(name) # Verify the extension directory still exists diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 1f2439b417..bef91a55c4 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -17,7 +17,7 @@ logger = get_logger(__name__) -class InstalledExtensionMetadata(BaseModel): +class InstallationMetadata(BaseModel): """Metadata file for tracking installed extensions.""" extensions: dict[str, InstallationInfo] = Field( @@ -33,7 +33,7 @@ def get_metadata_path(cls, installed_dir: Path) -> Path: return installed_dir / cls.metadata_filename @classmethod - def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata: + def load_from_dir(cls, installed_dir: Path) -> InstallationMetadata: """Load metadata from the installed extensions directory.""" metadata_path = cls.get_metadata_path(installed_dir) if not metadata_path.exists(): @@ -42,7 +42,7 @@ def load_from_dir(cls, installed_dir: Path) -> InstalledExtensionMetadata: try: with metadata_path.open() as f: data = json.load(f) - return cls.model_validate(data) + return cls.model_validate_json(data) except Exception as e: logger.warning(f"Failed to load installed extension metadata: {e}") @@ -53,7 +53,7 @@ def save_to_dir(self, installed_dir: Path) -> None: metadata_path = self.get_metadata_path(installed_dir) metadata_path.parent.mkdir(parents=True, exist_ok=True) with metadata_path.open("w") as f: - json.dump(self.model_dump(), f, indent=2) + json.dump(self.model_dump_json(), f, indent=2) def validate_tracked( self, installed_dir: Path diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index e69de29bb2..f1e7db689a 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from openhands.sdk.extensions.installation import InstallationInfo, InstallationMetadata + + +def test_load_from_dir_nonexistent(tmp_path: Path): + """Test loading metadata from nonexistent directory returns empty.""" + metadata = InstallationMetadata.load_from_dir(tmp_path / "nonexistent") + assert metadata.extensions == {} + + +def test_load_from_dir_and_save_to_dir(tmp_path: Path): + """Test saving and loading metadata.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + info = InstallationInfo( + name="test-extension", + version="1.0.0", + description="Test", + source="github:owner/test", + install_path=installation_dir / "test-extension", + ) + + metadata = InstallationMetadata(extensions={"test-extension": info}) + metadata.save_to_dir(installation_dir) + + loaded_metadata = InstallationMetadata.load_from_dir(installation_dir) + + assert metadata == loaded_metadata + + +def test_load_from_dir_invalid_json(tmp_path: Path): + """Test loading invalid JSON returns empty metadata.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + metadata_path = InstallationMetadata.get_metadata_path(installation_dir) + metadata_path.write_text("invalid json {") + + metadata = InstallationMetadata.load_from_dir(installation_dir) + assert metadata.extensions == {} From eb7f34866060529459559a45e26e8ff72729dfa4 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 13:20:22 -0600 Subject: [PATCH 34/51] installation manager rename -> install tests --- .../openhands/sdk/extensions/installation/__init__.py | 4 ++-- .../openhands/sdk/extensions/installation/manager.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index 2921e50d2d..d2d2e096a5 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -3,7 +3,7 @@ ExtensionProtocol, InstallationInterface, ) -from openhands.sdk.extensions.installation.manager import InstalledExtensionManager +from openhands.sdk.extensions.installation.manager import InstallationManager from openhands.sdk.extensions.installation.metadata import InstallationMetadata @@ -11,6 +11,6 @@ "InstallationInfo", "InstallationInterface", "ExtensionProtocol", - "InstalledExtensionManager", + "InstallationManager", "InstallationMetadata", ] diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index cef9326df9..b30337ba63 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -19,7 +19,7 @@ @dataclass -class InstalledExtensionManager[T: ExtensionProtocol]: +class InstallationManager[T: ExtensionProtocol]: """Manages installed extensions.""" installation_dir: Path @@ -27,7 +27,7 @@ class InstalledExtensionManager[T: ExtensionProtocol]: def install( self, - source: str, + source: str | Path, ref: str | None = None, repo_path: str | None = None, force: bool = False, @@ -60,6 +60,9 @@ def install( >>> info = install("github:owner/my-extension", ref="v1.0.0") >>> print(f"Installed {info.name} from {info.source}") """ + if isinstance(source, Path): + source = str(source) + # Fetch the extension (downloads to cache if remote) logger.info(f"Fetching extension from {source}") fetched_path, resolved_ref = fetch_with_resolution( From 38368065fc2b449215d006d85e1bdd696d5d8ade Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 13:21:11 -0600 Subject: [PATCH 35/51] tests for install --- .../installation/test_installation_manager.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/sdk/extensions/installation/test_installation_manager.py b/tests/sdk/extensions/installation/test_installation_manager.py index e69de29bb2..ea21b3489b 100644 --- a/tests/sdk/extensions/installation/test_installation_manager.py +++ b/tests/sdk/extensions/installation/test_installation_manager.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import pytest +from litellm import json +from pydantic import BaseModel + +from openhands.sdk.extensions.installation import ( + InstallationInterface, + InstallationManager, + InstallationMetadata, +) + + +class MockExtension(BaseModel): + name: str + version: str + description: str + + +class MockExtensionInstallationInterface(InstallationInterface): + @staticmethod + def load_from_dir(extension_dir: Path) -> MockExtension: + extension_path: Path = extension_dir / "extension.json" + with extension_path.open() as f: + return MockExtension.model_validate_json(json.load(f)) + + +@pytest.fixture +def mock_extension() -> MockExtension: + """Builds an instance of the mock extension class.""" + return MockExtension( + name="mock-extension", version="0.0.1", description="Mock extension" + ) + + +@pytest.fixture +def mock_extension_dir(mock_extension: MockExtension, tmp_path: Path) -> Path: + """Builds a temporary directory for the mock extension, loadable using + `load_from_dir` functions. + """ + extension_dir: Path = tmp_path / "mock-extension" + extension_dir.mkdir(parents=True, exist_ok=True) + + extension_path: Path = extension_dir / "extension.json" + with extension_path.open("w") as f: + json.dump(mock_extension.model_dump_json(), f) + + return extension_dir + + +@pytest.fixture +def installation_dir(tmp_path: Path) -> Path: + """Builds an installation directory.""" + installation_dir: Path = tmp_path / "installed" + installation_dir.mkdir(parents=True, exist_ok=True) + return installation_dir + + +def test_install_from_local_path( + mock_extension_dir: Path, installation_dir: Path, mock_extension: MockExtension +): + """Test extensions can be installed from local source.""" + manager = InstallationManager( + installation_dir=installation_dir, + installation_interface=MockExtensionInstallationInterface(), + ) + + extension_info = manager.install(str(mock_extension_dir)) + + # Verify the produced info matches the mock extension + assert extension_info.name == mock_extension.name + assert extension_info.version == mock_extension.version + assert extension_info.description == mock_extension.description + + # Verify the extension was copied to the installation directory + extension_dir = installation_dir / mock_extension.name + assert extension_dir.exists() + assert (extension_dir / "extension.json").exists() + + # Verify metadata was updated + metadata = InstallationMetadata.load_from_dir(installation_dir) + assert mock_extension.name in metadata.extensions + + +def test_install_already_exist_raises_error( + mock_extension_dir: Path, installation_dir: Path +): + """Tests that installing an existing plugin raises FileExistsError unless forced.""" + manager = InstallationManager( + installation_dir=installation_dir, + installation_interface=MockExtensionInstallationInterface(), + ) + + manager.install(mock_extension_dir) + + with pytest.raises(FileExistsError): + manager.install(mock_extension_dir) + + assert manager.install(mock_extension_dir, force=True) + + +def test_install_with_force_overwrites( + mock_extension_dir: Path, installation_dir: Path, mock_extension: MockExtension +): + """Test that force=True overwrites existing installation.""" + manager = InstallationManager( + installation_dir=installation_dir, + installation_interface=MockExtensionInstallationInterface(), + ) + + manager.install(mock_extension_dir) + + marker_file = installation_dir / mock_extension.name / "marker.txt" + marker_file.write_text("MARK") + assert marker_file.exists() + + manager.install(mock_extension_dir, force=True) + + assert not marker_file.exists() From 5152d82d63b6da5ae3d5ab03ccc851a8794947aa Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 13:32:10 -0600 Subject: [PATCH 36/51] more tests for manager --- .../installation/test_installation_manager.py | 338 ++++++++++++++++-- 1 file changed, 308 insertions(+), 30 deletions(-) diff --git a/tests/sdk/extensions/installation/test_installation_manager.py b/tests/sdk/extensions/installation/test_installation_manager.py index ea21b3489b..a62cda16b1 100644 --- a/tests/sdk/extensions/installation/test_installation_manager.py +++ b/tests/sdk/extensions/installation/test_installation_manager.py @@ -1,7 +1,8 @@ +import json +import shutil from pathlib import Path import pytest -from litellm import json from pydantic import BaseModel from openhands.sdk.extensions.installation import ( @@ -25,6 +26,20 @@ def load_from_dir(extension_dir: Path) -> MockExtension: return MockExtension.model_validate_json(json.load(f)) +def _write_mock_extension( + directory: Path, + name: str = "mock-extension", + version: str = "0.0.1", + description: str = "Mock extension", +) -> Path: + """Write a mock extension manifest to a directory.""" + directory.mkdir(parents=True, exist_ok=True) + ext = MockExtension(name=name, version=version, description=description) + with (directory / "extension.json").open("w") as f: + json.dump(ext.model_dump_json(), f) + return directory + + @pytest.fixture def mock_extension() -> MockExtension: """Builds an instance of the mock extension class.""" @@ -38,14 +53,12 @@ def mock_extension_dir(mock_extension: MockExtension, tmp_path: Path) -> Path: """Builds a temporary directory for the mock extension, loadable using `load_from_dir` functions. """ - extension_dir: Path = tmp_path / "mock-extension" - extension_dir.mkdir(parents=True, exist_ok=True) - - extension_path: Path = extension_dir / "extension.json" - with extension_path.open("w") as f: - json.dump(mock_extension.model_dump_json(), f) - - return extension_dir + return _write_mock_extension( + tmp_path / "mock-extension", + name=mock_extension.name, + version=mock_extension.version, + description=mock_extension.description, + ) @pytest.fixture @@ -56,41 +69,46 @@ def installation_dir(tmp_path: Path) -> Path: return installation_dir -def test_install_from_local_path( - mock_extension_dir: Path, installation_dir: Path, mock_extension: MockExtension -): - """Test extensions can be installed from local source.""" - manager = InstallationManager( +@pytest.fixture +def manager(installation_dir: Path) -> InstallationManager[MockExtension]: + """Builds an InstallationManager with the mock interface.""" + return InstallationManager( installation_dir=installation_dir, installation_interface=MockExtensionInstallationInterface(), ) + +# ============================================================================ +# Install Tests +# ============================================================================ + + +def test_install_from_local_path( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, + mock_extension: MockExtension, +): + """Test extensions can be installed from local source.""" extension_info = manager.install(str(mock_extension_dir)) - # Verify the produced info matches the mock extension assert extension_info.name == mock_extension.name assert extension_info.version == mock_extension.version assert extension_info.description == mock_extension.description - # Verify the extension was copied to the installation directory extension_dir = installation_dir / mock_extension.name assert extension_dir.exists() assert (extension_dir / "extension.json").exists() - # Verify metadata was updated metadata = InstallationMetadata.load_from_dir(installation_dir) assert mock_extension.name in metadata.extensions def test_install_already_exist_raises_error( - mock_extension_dir: Path, installation_dir: Path + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, ): - """Tests that installing an existing plugin raises FileExistsError unless forced.""" - manager = InstallationManager( - installation_dir=installation_dir, - installation_interface=MockExtensionInstallationInterface(), - ) - + """Test that installing an existing extension raises FileExistsError.""" manager.install(mock_extension_dir) with pytest.raises(FileExistsError): @@ -100,14 +118,12 @@ def test_install_already_exist_raises_error( def test_install_with_force_overwrites( - mock_extension_dir: Path, installation_dir: Path, mock_extension: MockExtension + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, + mock_extension: MockExtension, ): """Test that force=True overwrites existing installation.""" - manager = InstallationManager( - installation_dir=installation_dir, - installation_interface=MockExtensionInstallationInterface(), - ) - manager.install(mock_extension_dir) marker_file = installation_dir / mock_extension.name / "marker.txt" @@ -117,3 +133,265 @@ def test_install_with_force_overwrites( manager.install(mock_extension_dir, force=True) assert not marker_file.exists() + + +def test_install_invalid_extension_name_raises_error( + manager: InstallationManager[MockExtension], + tmp_path: Path, +): + """Test that installing an extension with an invalid manifest name fails.""" + bad_dir = _write_mock_extension(tmp_path / "bad-ext", name="bad_name") + + with pytest.raises(ValueError, match="Invalid extension name"): + manager.install(str(bad_dir)) + + +# ============================================================================ +# Uninstall Tests +# ============================================================================ + + +def test_uninstall_existing_extension( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test uninstalling an existing extension.""" + manager.install(str(mock_extension_dir)) + + result = manager.uninstall("mock-extension") + + assert result is True + assert not (installation_dir / "mock-extension").exists() + + metadata = InstallationMetadata.load_from_dir(installation_dir) + assert "mock-extension" not in metadata.extensions + + +def test_uninstall_nonexistent_extension( + manager: InstallationManager[MockExtension], +): + """Test uninstalling an extension that doesn't exist.""" + result = manager.uninstall("nonexistent") + assert result is False + + +def test_uninstall_untracked_extension_does_not_delete( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test that uninstall refuses to delete untracked extension directories.""" + dest = installation_dir / "untracked-ext" + shutil.copytree(mock_extension_dir, dest) + + # Rewrite the manifest so the name matches the directory + _write_mock_extension(dest, name="untracked-ext") + + result = manager.uninstall("untracked-ext") + + assert result is False + assert dest.exists() + + +def test_uninstall_invalid_name_raises_error( + manager: InstallationManager[MockExtension], +): + """Test that invalid extension names are rejected.""" + with pytest.raises(ValueError, match="Invalid extension name"): + manager.uninstall("../evil") + + +# ============================================================================ +# List Installed Tests +# ============================================================================ + + +def test_list_empty_directory( + manager: InstallationManager[MockExtension], +): + """Test listing extensions from empty directory.""" + extensions = manager.list_installed() + assert extensions == [] + + +def test_list_installed_extensions( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test listing installed extensions.""" + manager.install(str(mock_extension_dir)) + + extensions = manager.list_installed() + + assert len(extensions) == 1 + assert extensions[0].name == "mock-extension" + assert extensions[0].version == "0.0.1" + + +def test_list_discovers_untracked_extensions( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test that list discovers extensions not in metadata.""" + dest = installation_dir / "manual-ext" + shutil.copytree(mock_extension_dir, dest) + _write_mock_extension(dest, name="manual-ext") + + extensions = manager.list_installed() + + assert len(extensions) == 1 + assert extensions[0].name == "manual-ext" + assert extensions[0].source == "local" + + +def test_list_cleans_up_missing_extensions( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test that list removes metadata for missing extensions.""" + manager.install(str(mock_extension_dir)) + + shutil.rmtree(installation_dir / "mock-extension") + + extensions = manager.list_installed() + + assert len(extensions) == 0 + metadata = InstallationMetadata.load_from_dir(installation_dir) + assert "mock-extension" not in metadata.extensions + + +# ============================================================================ +# Load Installed Tests +# ============================================================================ + + +def test_load_empty_directory( + manager: InstallationManager[MockExtension], +): + """Test loading extensions from empty directory.""" + extensions = manager.load_installed() + assert extensions == [] + + +def test_load_installed_extensions( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test loading installed extensions.""" + manager.install(str(mock_extension_dir)) + + extensions = manager.load_installed() + + assert len(extensions) == 1 + assert extensions[0].name == "mock-extension" + + +def test_disable_extension_filters_load( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test that disabled extensions are excluded from load.""" + manager.install(str(mock_extension_dir)) + + assert manager.disable("mock-extension") is True + + extensions = manager.load_installed() + assert extensions == [] + + info = manager.get("mock-extension") + assert info is not None + assert info.enabled is False + + +def test_enable_extension_restores_load( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test that re-enabled extensions are loaded again.""" + manager.install(str(mock_extension_dir)) + manager.disable("mock-extension") + + assert manager.enable("mock-extension") is True + + extensions = manager.load_installed() + assert len(extensions) == 1 + assert extensions[0].name == "mock-extension" + + +# ============================================================================ +# Get Extension Tests +# ============================================================================ + + +def test_get_existing_extension( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test getting info for an existing extension.""" + manager.install(str(mock_extension_dir)) + + info = manager.get("mock-extension") + + assert info is not None + assert info.name == "mock-extension" + + +def test_get_nonexistent_extension( + manager: InstallationManager[MockExtension], +): + """Test getting info for a nonexistent extension.""" + info = manager.get("nonexistent") + assert info is None + + +def test_get_extension_with_missing_directory( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test getting info when extension directory is missing.""" + manager.install(str(mock_extension_dir)) + + shutil.rmtree(installation_dir / "mock-extension") + + info = manager.get("mock-extension") + assert info is None + + +# ============================================================================ +# Update Extension Tests +# ============================================================================ + + +def test_update_existing_extension_local( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test updating an installed extension from local source.""" + manager.install(str(mock_extension_dir)) + manager.disable("mock-extension") + + # Modify the source to a new version + _write_mock_extension( + mock_extension_dir, + name="mock-extension", + version="0.0.2", + description="Updated extension", + ) + + updated = manager.update("mock-extension") + + assert updated is not None + assert updated.version == "0.0.2" + assert updated.enabled is False + + +def test_update_nonexistent_extension( + manager: InstallationManager[MockExtension], +): + """Test updating an extension that doesn't exist.""" + info = manager.update("nonexistent") + assert info is None From cac66936b852f37ccc3467e3ee45f94119287b0f Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 13:46:52 -0600 Subject: [PATCH 37/51] improved test coverage --- .../installation/test_installation_manager.py | 61 ++++++++++++ .../test_installation_metadata.py | 99 ++++++++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/tests/sdk/extensions/installation/test_installation_manager.py b/tests/sdk/extensions/installation/test_installation_manager.py index a62cda16b1..219b00bfbd 100644 --- a/tests/sdk/extensions/installation/test_installation_manager.py +++ b/tests/sdk/extensions/installation/test_installation_manager.py @@ -146,6 +146,19 @@ def test_install_invalid_extension_name_raises_error( manager.install(str(bad_dir)) +def test_install_force_preserves_enabled_state( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, +): + """Test that force reinstall preserves the existing enabled state.""" + manager.install(str(mock_extension_dir)) + manager.disable("mock-extension") + + info = manager.install(mock_extension_dir, force=True) + + assert info.enabled is False + + # ============================================================================ # Uninstall Tests # ============================================================================ @@ -194,6 +207,22 @@ def test_uninstall_untracked_extension_does_not_delete( assert dest.exists() +def test_uninstall_tracked_but_directory_missing( + manager: InstallationManager[MockExtension], + mock_extension_dir: Path, + installation_dir: Path, +): + """Test that uninstall succeeds when tracked but directory was already deleted.""" + manager.install(str(mock_extension_dir)) + shutil.rmtree(installation_dir / "mock-extension") + + result = manager.uninstall("mock-extension") + + assert result is True + metadata = InstallationMetadata.load_from_dir(installation_dir) + assert "mock-extension" not in metadata.extensions + + def test_uninstall_invalid_name_raises_error( manager: InstallationManager[MockExtension], ): @@ -207,6 +236,15 @@ def test_uninstall_invalid_name_raises_error( # ============================================================================ +def test_list_nonexistent_installation_dir(tmp_path: Path): + """Test listing when installation_dir doesn't exist returns empty.""" + manager = InstallationManager( + installation_dir=tmp_path / "does-not-exist", + installation_interface=MockExtensionInstallationInterface(), + ) + assert manager.list_installed() == [] + + def test_list_empty_directory( manager: InstallationManager[MockExtension], ): @@ -268,6 +306,15 @@ def test_list_cleans_up_missing_extensions( # ============================================================================ +def test_load_nonexistent_installation_dir(tmp_path: Path): + """Test loading when installation_dir doesn't exist returns empty.""" + manager = InstallationManager( + installation_dir=tmp_path / "does-not-exist", + installation_interface=MockExtensionInstallationInterface(), + ) + assert manager.load_installed() == [] + + def test_load_empty_directory( manager: InstallationManager[MockExtension], ): @@ -321,6 +368,20 @@ def test_enable_extension_restores_load( assert extensions[0].name == "mock-extension" +def test_enable_nonexistent_extension_returns_false( + manager: InstallationManager[MockExtension], +): + """Test that enabling a nonexistent extension returns False.""" + assert manager.enable("nonexistent") is False + + +def test_disable_nonexistent_extension_returns_false( + manager: InstallationManager[MockExtension], +): + """Test that disabling a nonexistent extension returns False.""" + assert manager.disable("nonexistent") is False + + # ============================================================================ # Get Extension Tests # ============================================================================ diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index f1e7db689a..8097ca6f13 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -1,6 +1,46 @@ +import json from pathlib import Path -from openhands.sdk.extensions.installation import InstallationInfo, InstallationMetadata +from pydantic import BaseModel + +from openhands.sdk.extensions.installation import ( + InstallationInfo, + InstallationInterface, + InstallationMetadata, +) + + +class MockExtension(BaseModel): + name: str + version: str + description: str + + +class MockExtensionInstallationInterface(InstallationInterface): + @staticmethod + def load_from_dir(extension_dir: Path) -> MockExtension: + extension_path: Path = extension_dir / "extension.json" + with extension_path.open() as f: + return MockExtension.model_validate_json(json.load(f)) + + +def _write_mock_extension( + directory: Path, + name: str = "mock-extension", + version: str = "0.0.1", + description: str = "Mock extension", +) -> Path: + """Write a mock extension manifest to a directory.""" + directory.mkdir(parents=True, exist_ok=True) + ext = MockExtension(name=name, version=version, description=description) + with (directory / "extension.json").open("w") as f: + json.dump(ext.model_dump_json(), f) + return directory + + +# ============================================================================ +# Load / Save Tests +# ============================================================================ def test_load_from_dir_nonexistent(tmp_path: Path): @@ -40,3 +80,60 @@ def test_load_from_dir_invalid_json(tmp_path: Path): metadata = InstallationMetadata.load_from_dir(installation_dir) assert metadata.extensions == {} + + +# ============================================================================ +# validate_tracked Tests +# ============================================================================ + + +def test_validate_tracked_prunes_invalid_names(tmp_path: Path): + """Test that validate_tracked removes entries with invalid names.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + bad_info = InstallationInfo( + name="Bad_Name", + source="local", + install_path=installation_dir / "Bad_Name", + ) + good_info = InstallationInfo( + name="good-ext", + source="local", + install_path=installation_dir / "good-ext", + ) + (installation_dir / "good-ext").mkdir() + + metadata = InstallationMetadata( + extensions={"Bad_Name": bad_info, "good-ext": good_info} + ) + + valid, changed = metadata.validate_tracked(installation_dir) + + assert changed is True + assert len(valid) == 1 + assert valid[0].name == "good-ext" + assert "Bad_Name" not in metadata.extensions + + +# ============================================================================ +# discover_untracked Tests +# ============================================================================ + + +def test_discover_untracked_skips_mismatched_manifest_name(tmp_path: Path): + """Test that discover skips dirs where manifest name doesn't match dir name.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + # Create a dir named "some-ext" but manifest says "other-name" + _write_mock_extension(installation_dir / "some-ext", name="other-name") + + metadata = InstallationMetadata() + interface = MockExtensionInstallationInterface() + + discovered, changed = metadata.discover_untracked(installation_dir, interface) + + assert discovered == [] + assert changed is False + assert "some-ext" not in metadata.extensions From 078fb2a75d5107b3c290e66b67ffcd95b789a694 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 13:52:04 -0600 Subject: [PATCH 38/51] docs pass --- .../sdk/extensions/installation/README.md | 92 +++++++++++++- .../sdk/extensions/installation/info.py | 14 ++- .../sdk/extensions/installation/interface.py | 13 +- .../sdk/extensions/installation/manager.py | 119 +++++++++--------- .../sdk/extensions/installation/metadata.py | 11 +- .../sdk/extensions/installation/utils.py | 5 +- 6 files changed, 175 insertions(+), 79 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/README.md b/openhands-sdk/openhands/sdk/extensions/installation/README.md index 64243f83e5..3c605fca42 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/README.md +++ b/openhands-sdk/openhands/sdk/extensions/installation/README.md @@ -1,11 +1,93 @@ # Installation -This module provides utilities for installing, tracking, and loading extensions. +Generic framework for installing, tracking, and loading extensions from local +or remote sources. -## How to Use +## Overview -The main entry point is `InstalledExtensionManager`. When constructing the manager, we must provide an installation directory and an installation interface. The latter provides methods to load an extension from disk and to generate installation metadata. +The installation module is **extension-type agnostic**. It is parameterised by +a type `T` (any object with `name`, `version`, and `description` attributes) +and an `InstallationInterface[T]` that knows how to load `T` from a directory. +Everything else — fetching, copying, metadata bookkeeping, enable/disable +state — is handled generically. -EXAMPLE SOURCE GOES HERE +## Usage -Once the `InstalledExtensionManager` is instantiated, you can install/uninstall extensions, enable/disable installed extensions, and get information about the currently-managed extensions. +### 1. Define your extension type and loader + +```python +from pathlib import Path +from pydantic import BaseModel +from openhands.sdk.extensions.installation import ( + InstallationInterface, + InstallationManager, +) + +class Widget(BaseModel): + name: str + version: str + description: str + +class WidgetLoader(InstallationInterface[Widget]): + @staticmethod + def load_from_dir(extension_dir: Path) -> Widget: + return Widget.model_validate_json( + (extension_dir / "widget.json").read_text() + ) +``` + +### 2. Create a manager + +```python +manager = InstallationManager( + installation_dir=Path("~/.myapp/widgets/installed").expanduser(), + installation_interface=WidgetLoader(), +) +``` + +### 3. Manage extensions + +```python +# Install from a local path or remote source +info = manager.install("github:owner/my-widget", ref="v1.0.0") +info = manager.install("/path/to/local/widget") + +# Force-overwrite an existing installation (preserves enabled state) +info = manager.install("github:owner/my-widget", force=True) + +# List / load +all_info = manager.list_installed() # List[InstallationInfo] +widgets = manager.load_installed() # List[Widget] (enabled only) + +# Enable / disable +manager.disable("my-widget") # excluded from load_installed() +manager.enable("my-widget") # included again + +# Look up a single extension +info = manager.get("my-widget") # InstallationInfo | None + +# Update to latest from the original source +info = manager.update("my-widget") + +# Remove completely +manager.uninstall("my-widget") +``` + +## Self-healing metadata + +`list_installed()` (and by extension `load_installed()`) automatically +reconciles the `.installed.json` metadata with what is actually on disk: + +- **Stale entries** — if a tracked extension's directory has been manually + deleted, the metadata entry is pruned. +- **Untracked directories** — if a valid extension directory exists but is not + in metadata, it is discovered and added with `source="local"`. + +This means the metadata file is always the single source of truth *after* a +list/load call, even if the filesystem was modified externally. + +## Extension naming + +Extension names must be **kebab-case** (`^[a-z0-9]+(-[a-z0-9]+)*$`). This is +enforced on install, uninstall, enable, disable, get, and update to prevent +path-traversal attacks (e.g. `../evil`). diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index d190886930..6a3411e3f6 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -9,9 +9,10 @@ class InstallationInfo(BaseModel): - """Information about an installed extension. + """Metadata record for a single installed extension. - Linked to extensions by the installed extension metadata. + Stored (keyed by name) inside ``InstallationMetadata`` and persisted to + the ``.installed.json`` file in the installation directory. """ name: str = Field(description="Extension name") @@ -43,11 +44,14 @@ def from_extension( resolved_ref: str | None = None, repo_path: str | None = None, ) -> InstallationInfo: - """Construct an InstallationInfo object from an extension, plus relevant - installation information. + """Create an InstallationInfo from an extension and its install context. Args: - extension: Any installable extension object. + extension: Any object satisfying ``ExtensionProtocol``. + source: Original source string (e.g. ``"github:owner/repo"``). + install_path: Filesystem path the extension was copied to. + resolved_ref: Resolved git commit SHA, if applicable. + repo_path: Subdirectory within a monorepo, if applicable. """ return InstallationInfo( name=extension.name, diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 6ff64ad85e..2e5b9c5364 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -4,10 +4,11 @@ class ExtensionProtocol(Protocol): - """Protocol defining the expected fields needed for an extension. + """Structural protocol for installable extensions. - These fields are necessary to construct the InstallationInfo object fully, and are - usually guaranteed by the relevant Anthropic standards. + Any object with these three attributes can be managed by the + installation system. The fields map directly to + ``InstallationInfo.name``, ``.version``, and ``.description``. """ name: str @@ -16,6 +17,12 @@ class ExtensionProtocol(Protocol): class InstallationInterface[T: ExtensionProtocol](ABC): + """Abstract interface that teaches ``InstallationManager`` how to load ``T``. + + Subclass this and implement ``load_from_dir`` for each concrete + extension type (e.g. plugins, skills). + """ + @staticmethod @abstractmethod def load_from_dir(extension_dir: Path) -> T: ... diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index b30337ba63..6b093e8ecb 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -20,7 +20,17 @@ @dataclass class InstallationManager[T: ExtensionProtocol]: - """Manages installed extensions.""" + """Generic manager for installing, tracking, and loading extensions. + + Parameterised by any type ``T`` that satisfies ``ExtensionProtocol``. + The companion ``InstallationInterface[T]`` tells the manager how to + load ``T`` from a directory on disk; everything else (fetching, copying, + metadata bookkeeping) is handled generically. + + Attributes: + installation_dir: Root directory where extensions are installed. + installation_interface: Knows how to load ``T`` from a directory. + """ installation_dir: Path installation_interface: InstallationInterface[T] @@ -34,31 +44,26 @@ def install( ) -> InstallationInfo: """Install an extension from a source. - Fetches the extensionFrom the source, copies it to the installed extensions - directory, and records the installation metadata. + Fetches the extension from the source, copies it to the installation + directory, and records installation metadata. When ``force=True`` + overwrites an existing installation, the previous ``enabled`` state is + preserved. Args: - source: Extension source - can be: - - "github:owner/repo" - GitHub shorthand - - Any git URL (GitHub, GitLab, Bitbucket, etc.) - - Local path (for development/testing) + source: Extension source — can be a ``"github:owner/repo"`` + shorthand, any git URL, or a local filesystem path. ref: Optional branch, tag, or commit to install. repo_path: Subdirectory path within the repository (for monorepos). - force: If True, overwrite existing installation. If False, raise error - if extension is already installed. + force: If True, overwrite existing installation. If False, raise + an error if the extension is already installed. Returns: - InstalledExtensionInfoBaseClass[T] instance with details about the - installation. + InstallationInfo with details about the installation. Raises: ExtensionFetchError: If fetching the extension fails. FileExistsError: If extension is already installed and force=False. - ValueError: If the extension manifest is invalid. - - Example: - >>> info = install("github:owner/my-extension", ref="v1.0.0") - >>> print(f"Installed {info.name} from {info.source}") + ValueError: If the extension name is invalid. """ if isinstance(source, Path): source = str(source) @@ -121,21 +126,19 @@ def install( def uninstall(self, name: str) -> bool: """Uninstall an extension by name. - Only extensions tracked in the installed extensions metadata file can be - uninstalled. This avoids deleting arbitrary directories in the installed - extensions directory. + Only extensions tracked in the metadata can be uninstalled. This + prevents accidentally deleting arbitrary directories that happen to + exist inside the installation directory. If the extension's directory + has already been removed, the metadata entry is still cleaned up. Args: name: Name of the extension to uninstall. Returns: - True if the extension was uninstalled, False if it wasn't installed. + True if the extension was uninstalled, False if it wasn't tracked. - Example: - >>> if uninstall("my-extension"): - ... print("Extension uninstalled") - ... else: - ... print("Extension was not installed") + Raises: + ValueError: If *name* is not valid kebab-case. """ validate_extension_name(name) @@ -164,6 +167,13 @@ def _set_enabled( name: str, enabled: bool, ) -> bool: + """Set the enabled state of an installed extension. + + Syncs metadata before checking, so stale or untracked entries are + reconciled first. Returns False without saving if the extension is + not installed, its directory is missing, or it already has the + requested state. + """ validate_extension_name(name) if not self.installation_dir.exists(): @@ -209,16 +219,12 @@ def disable(self, name: str) -> bool: def list_installed(self) -> list[InstallationInfo]: """List all installed extensions. - This function is self-healing: it may update the installed extensions metadata - file to remove entries whose directories were deleted, and to add entries for - extension directories that were manually copied into the installed dir. + Self-healing: the metadata file is updated to remove entries whose + directories have been deleted and to add entries for extension + directories that were manually copied into the installation directory. Returns: - List of InstalledExtensionInfoBaseClass[T] for each installed extension. - - Example: - >>> for info in list_installed(): - ... print(f"{info.name} v{info.version}") + List of InstallationInfo for each installed extension. """ if not self.installation_dir.exists(): return [] @@ -230,18 +236,14 @@ def list_installed(self) -> list[InstallationInfo]: return info def load_installed(self) -> list[T]: - """Load all installed extensions. + """Load all enabled extensions as ``T`` objects. - Loads extension objects for all extensions in the installed extensions - directory. This is useful for integrating installed extensions into an agent. + Calls ``list_installed()`` first (which syncs metadata), then loads + each enabled extension via the installation interface. Disabled + extensions are skipped. Returns: - List of loaded extension objects. - - Example: - >>> extension = load_installed() - >>> for ext in extensions: - ... print(f"Loaded {ext}") + List of loaded extension objects of type ``T``. """ if not self.installation_dir.exists(): return [] @@ -262,18 +264,17 @@ def load_installed(self) -> list[T]: def get(self, name: str) -> InstallationInfo | None: """Get information about a specific installed extension. + Returns ``None`` if the extension is not tracked in metadata or if + its directory no longer exists on disk. + Args: name: Name of the extension to look up. - installed_dir: Directory for installed extensions. Returns: - InstalledExtensionInfoBaseClass[T] if the extension is installed, None - otherwise. + InstallationInfo if the extension is installed, None otherwise. - Example: - >>> info = get("my-extension") - >>> if info: - ... print(f"Installed from {info.source} at {info.installed_at}") + Raises: + ValueError: If *name* is not valid kebab-case. """ validate_extension_name(name) @@ -291,27 +292,21 @@ def get(self, name: str) -> InstallationInfo | None: def update(self, name: str) -> InstallationInfo | None: """Update an installed extension to the latest version. - Re-fetches the extension from its original source and reinstalls it. - - This always updates to the latest version available from the original source - (i.e., it does not preserve a pinned ref). + Re-fetches the extension from its original source with ``ref=None`` + (i.e. the latest available) and force-reinstalls it. The previous + ``enabled`` state is preserved because ``install(force=True)`` + carries it over. Args: name: Name of the extension to update. - installed_dir: Directory for installed extensions. Defaults to the - installed_dir instance variable. Returns: - Updated InstalledExtensionInfoBaseClass[T] if successful, None if extension - not installed. + Updated InstallationInfo if successful, None if the extension is + not installed. Raises: ExtensionFetchError: If fetching the updated extension fails. - - Example: - >>> info = update("my-extension") - >>> if info: - ... print(f"Updated to v{info.version}") + ValueError: If *name* is not valid kebab-case. """ validate_extension_name(name) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index bef91a55c4..2a8ee23303 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -149,8 +149,15 @@ def discover_untracked( def sync_installed( self, installed_dir: Path, installation_interface: InstallationInterface ) -> list[InstallationInfo]: - # TODO: Doc-string - # TODO: add context manager for this class that loads, syncs, then forces a save + """Reconcile metadata with what is actually on disk. + + Runs ``validate_tracked`` (prunes stale entries) then + ``discover_untracked`` (adds new entries), and persists the metadata + file if either step made changes. + + Returns: + Combined list of valid tracked and newly discovered extensions. + """ valid_extensions, tracked_changed = self.validate_tracked(installed_dir) discovered, discovered_changed = self.discover_untracked( installed_dir, installation_interface diff --git a/openhands-sdk/openhands/sdk/extensions/installation/utils.py b/openhands-sdk/openhands/sdk/extensions/installation/utils.py index a705b3b9c3..46c2ba65d8 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/utils.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/utils.py @@ -6,9 +6,10 @@ def validate_extension_name(name: str) -> None: - """Validate extension name is Claude-like kebab-case. + """Validate that *name* is kebab-case (``^[a-z0-9]+(-[a-z0-9]+)*$``). - This protects filesystem operations (install/uninstall) from path traversal. + Raises: + ValueError: If *name* does not match the pattern. """ if not _EXTENSION_NAME_PATTERN.fullmatch(name): raise ValueError(f"Invalid extension name. Expected kebab-case, got {name!r}.") From 74d04f2c5f68beb052329d0d1c306dd8a7e50cda Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 14:16:11 -0600 Subject: [PATCH 39/51] metadata context manager --- .../sdk/extensions/installation/README.md | 10 ++ .../sdk/extensions/installation/__init__.py | 6 +- .../sdk/extensions/installation/manager.py | 114 ++++++------- .../sdk/extensions/installation/metadata.py | 154 ++++++++++++------ .../test_installation_metadata.py | 54 +++++- 5 files changed, 222 insertions(+), 116 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/README.md b/openhands-sdk/openhands/sdk/extensions/installation/README.md index 3c605fca42..e466c873aa 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/README.md +++ b/openhands-sdk/openhands/sdk/extensions/installation/README.md @@ -11,6 +11,16 @@ and an `InstallationInterface[T]` that knows how to load `T` from a directory. Everything else — fetching, copying, metadata bookkeeping, enable/disable state — is handled generically. +### Module layout + +| File | Purpose | +|---|---| +| `interface.py` | `ExtensionProtocol` (structural typing contract) and `InstallationInterface[T]` (abstract loader) | +| `info.py` | `InstallationInfo` — Pydantic model stored per-extension in the metadata file | +| `metadata.py` | `InstallationMetadata` — persistence of `.installed.json`; `MetadataSession` — context manager that auto-saves on exit | +| `manager.py` | `InstallationManager[T]` — high-level API for install / uninstall / enable / disable / update | +| `utils.py` | `validate_extension_name` — kebab-case enforcement to prevent path traversal | + ## Usage ### 1. Define your extension type and loader diff --git a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py index d2d2e096a5..68403c5b19 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/__init__.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/__init__.py @@ -4,7 +4,10 @@ InstallationInterface, ) from openhands.sdk.extensions.installation.manager import InstallationManager -from openhands.sdk.extensions.installation.metadata import InstallationMetadata +from openhands.sdk.extensions.installation.metadata import ( + InstallationMetadata, + MetadataSession, +) __all__ = [ @@ -13,4 +16,5 @@ "ExtensionProtocol", "InstallationManager", "InstallationMetadata", + "MetadataSession", ] diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 6b093e8ecb..7577c08df2 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -10,7 +10,10 @@ ExtensionProtocol, InstallationInterface, ) -from openhands.sdk.extensions.installation.metadata import InstallationMetadata +from openhands.sdk.extensions.installation.metadata import ( + InstallationMetadata, + MetadataSession, +) from openhands.sdk.extensions.installation.utils import validate_extension_name from openhands.sdk.logger import get_logger @@ -35,6 +38,13 @@ class InstallationManager[T: ExtensionProtocol]: installation_dir: Path installation_interface: InstallationInterface[T] + @property + def metadata_session(self) -> MetadataSession: + """Open a metadata session bound to this manager's dir and interface.""" + return InstallationMetadata.open( + self.installation_dir, interface=self.installation_interface + ) + def install( self, source: str | Path, @@ -68,40 +78,33 @@ def install( if isinstance(source, Path): source = str(source) - # Fetch the extension (downloads to cache if remote) logger.info(f"Fetching extension from {source}") fetched_path, resolved_ref = fetch_with_resolution( source=source, - cache_dir=self.installation_dir - / ".cache", # TODO: check this cache value works + cache_dir=self.installation_dir / ".cache", ref=ref, repo_path=repo_path, update=True, ) - # Load the extension to get its metadata extension = self.installation_interface.load_from_dir(fetched_path) validate_extension_name(extension.name) - # Check if already installed install_path = self.installation_dir / extension.name if install_path.exists() and not force: raise FileExistsError( - f"Extension '{extension.name}' is already installed at {install_path}. " - f"Use force=True to overwrite." + f"Extension '{extension.name}' is already installed" + f" at {install_path}. Use force=True to overwrite." ) - # Remove existing installation if force=True if install_path.exists(): logger.info(f"Removing existing installation of '{extension.name}'") shutil.rmtree(install_path) - # Copy plugin to installed directory logger.info(f"Installing extension '{extension.name}' to {install_path}") self.installation_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(fetched_path, install_path) - # Create installation info info = InstallationInfo.from_extension( extension, source=source, @@ -110,13 +113,11 @@ def install( repo_path=repo_path, ) - # Update metadata - metadata = InstallationMetadata.load_from_dir(self.installation_dir) - existing_info = metadata.extensions.get(extension.name) - if existing_info is not None: - info.enabled = existing_info.enabled - metadata.extensions[extension.name] = info - metadata.save_to_dir(self.installation_dir) + with self.metadata_session as session: + existing = session.extensions.get(extension.name) + if existing is not None: + info.enabled = existing.enabled + session.extensions[extension.name] = info logger.info( f"Successfully installed extension '{extension.name}' v{info.version}" @@ -142,22 +143,21 @@ def uninstall(self, name: str) -> bool: """ validate_extension_name(name) - metadata = InstallationMetadata.load_from_dir(self.installation_dir) - if name not in metadata.extensions: - logger.warning(f"Plugin '{name}' is not installed") - return False + with self.metadata_session as session: + if name not in session.extensions: + logger.warning(f"Extension '{name}' is not installed") + return False - extension_path = self.installation_dir / name - if extension_path.exists(): - logger.info(f"Uninstalling extension '{name}' from {extension_path}") - shutil.rmtree(extension_path) - else: - logger.warning( - f"Extension '{name}' was tracked but {extension_path} is missing" - ) + extension_path = self.installation_dir / name + if extension_path.exists(): + logger.info(f"Uninstalling extension '{name}' from {extension_path}") + shutil.rmtree(extension_path) + else: + logger.warning( + f"Extension '{name}' was tracked but {extension_path} is missing" + ) - del metadata.extensions[name] - metadata.save_to_dir(self.installation_dir) + del session.extensions[name] logger.info(f"Successfully uninstalled extension '{name}'") return True @@ -170,9 +170,8 @@ def _set_enabled( """Set the enabled state of an installed extension. Syncs metadata before checking, so stale or untracked entries are - reconciled first. Returns False without saving if the extension is - not installed, its directory is missing, or it already has the - requested state. + reconciled first. Returns False if the extension is not installed + or its directory is missing. """ validate_extension_name(name) @@ -182,27 +181,26 @@ def _set_enabled( ) return False - metadata = InstallationMetadata.load_from_dir(self.installation_dir) - metadata.sync_installed(self.installation_dir, self.installation_interface) + with self.metadata_session as session: + session.sync() - info = metadata.extensions.get(name) - if info is None: - logger.warning(f"Extension '{name}' is not installed") - return False + info = session.extensions.get(name) + if info is None: + logger.warning(f"Extension '{name}' is not installed") + return False - extension_path = self.installation_dir / name - if not extension_path.exists(): - logger.warning( - f"Extension '{name}' was tracked but {extension_path} is missing" - ) - return False + extension_path = self.installation_dir / name + if not extension_path.exists(): + logger.warning( + f"Extension '{name}' was tracked but {extension_path} is missing" + ) + return False - if info.enabled == enabled: - return True + if info.enabled == enabled: + return True - info.enabled = enabled - metadata.extensions[name] = info - metadata.save_to_dir(self.installation_dir) + info.enabled = enabled + session.extensions[name] = info state = "enabled" if enabled else "disabled" logger.info(f"Successfully {state} extension '{name}'") @@ -229,11 +227,8 @@ def list_installed(self) -> list[InstallationInfo]: if not self.installation_dir.exists(): return [] - metadata = InstallationMetadata.load_from_dir(self.installation_dir) - info = metadata.sync_installed( - self.installation_dir, self.installation_interface - ) - return info + with self.metadata_session as session: + return session.sync() def load_installed(self) -> list[T]: """Load all enabled extensions as ``T`` objects. @@ -281,7 +276,6 @@ def get(self, name: str) -> InstallationInfo | None: metadata = InstallationMetadata.load_from_dir(self.installation_dir) info = metadata.extensions.get(name) - # Verify the extension directory still exists if info is not None: extension_path = self.installation_dir / name if not extension_path.exists(): @@ -310,17 +304,15 @@ def update(self, name: str) -> InstallationInfo | None: """ validate_extension_name(name) - # Get the current installation info current_info = self.get(name) if current_info is None: logger.warning(f"Extension {name} not installed") return None - # Re-install from the original source logger.info(f"Updating extension {name} from {current_info.source}") return self.install( source=current_info.source, - ref=None, # Get the latest (don't use pinned ref) + ref=None, repo_path=current_info.repo_path, force=True, ) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 2a8ee23303..3848a67101 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from types import TracebackType from typing import ClassVar from pydantic import BaseModel, Field @@ -17,8 +18,76 @@ logger = get_logger(__name__) +class MetadataSession: + """Context manager that binds ``InstallationMetadata`` to its directory. + + On a clean exit (no exception), the metadata is automatically saved. + This eliminates the need for callers to manually pair ``load_from_dir`` + and ``save_to_dir``, and guarantees that mutations are persisted. + + Use via ``InstallationMetadata.open(installed_dir)``. + """ + + def __init__( + self, + installed_dir: Path, + metadata: InstallationMetadata, + interface: InstallationInterface | None = None, + ) -> None: + self.installed_dir = installed_dir + self.metadata = metadata + self.interface = interface + + @property + def extensions(self) -> dict[str, InstallationInfo]: + return self.metadata.extensions + + def sync(self) -> list[InstallationInfo]: + """Reconcile metadata with what is actually on disk. + + Prunes stale tracked entries whose directories are missing and + discovers untracked extension directories. Does **not** save — + the enclosing ``with`` block handles persistence on exit. + + Requires that an ``InstallationInterface`` was provided when the + session was created (via ``InstallationMetadata.open(..., interface=...)``). + + Returns: + Combined list of valid tracked and newly discovered extensions. + """ + assert self.interface is not None, ( + "sync() requires an InstallationInterface; " + "pass interface= to InstallationMetadata.open()" + ) + valid = self.metadata.validate_tracked(self.installed_dir) + discovered = self.metadata.discover_untracked( + self.installed_dir, self.interface + ) + return valid + discovered + + def __enter__(self) -> MetadataSession: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if exc_type is None: + self.metadata.save_to_dir(self.installed_dir) + + class InstallationMetadata(BaseModel): - """Metadata file for tracking installed extensions.""" + """Metadata file for tracking installed extensions. + + Typically used via the ``open()`` context manager, which loads the + metadata, yields a ``MetadataSession``, and auto-saves on exit:: + + with InstallationMetadata.open(installed_dir) as session: + session.extensions["my-ext"] = info + # saved automatically + """ extensions: dict[str, InstallationInfo] = Field( default_factory=dict, @@ -27,6 +96,24 @@ class InstallationMetadata(BaseModel): metadata_filename: ClassVar[str] = ".installed.json" + @classmethod + def open( + cls, + installed_dir: Path, + *, + interface: InstallationInterface | None = None, + ) -> MetadataSession: + """Load metadata and return a session that auto-saves on exit. + + Args: + installed_dir: Root directory where extensions are installed. + interface: Optional installation interface, required if the + session will call ``sync()``. + """ + return MetadataSession( + installed_dir, cls.load_from_dir(installed_dir), interface + ) + @classmethod def get_metadata_path(cls, installed_dir: Path) -> Path: """Get the metadata file path for the installed extension directory.""" @@ -55,23 +142,19 @@ def save_to_dir(self, installed_dir: Path) -> None: with metadata_path.open("w") as f: json.dump(self.model_dump_json(), f, indent=2) - def validate_tracked( - self, installed_dir: Path - ) -> tuple[list[InstallationInfo], bool]: + def validate_tracked(self, installed_dir: Path) -> list[InstallationInfo]: """Validate tracked extensions exist on disk. - Removes any extension with an invalid name or missing directory. + Removes entries with invalid names or missing directories from + ``self.extensions`` in place. Returns: - Tuple of (valid extensions list, whether metadata was modified). + List of extensions that are still valid. """ valid_extensions: list[InstallationInfo] = [] - changed = False - # We cannot iterate directly over the extensions because we'll be removing - # invalid extensions as we go. + # Iterate over a snapshot because we mutate during the loop. for name, info in list(self.extensions.items()): - # Check the extension name try: validate_extension_name(name) except ValueError as e: @@ -79,10 +162,8 @@ def validate_tracked( f"Invalid tracked extension name {name!r}, removing: {e}" ) del self.extensions[name] - changed = True continue - # Check the extension installation extension_path = installed_dir / name if extension_path.exists(): valid_extensions.append(info) @@ -91,37 +172,36 @@ def validate_tracked( f"Extension {name} directory missing, removing from metadata" ) del self.extensions[name] - changed = True - return valid_extensions, changed + return valid_extensions def discover_untracked( - self, installed_dir: Path, installation_interface: InstallationInterface - ) -> tuple[list[InstallationInfo], bool]: + self, + installed_dir: Path, + installation_interface: InstallationInterface, + ) -> list[InstallationInfo]: """Discover extension directories not tracked by the metadata. + Adds newly found extensions to ``self.extensions`` in place. + Returns: - Tuple of (discovered extensions list, whether metadata was modified). + List of newly discovered extensions. """ discovered: list[InstallationInfo] = [] - changed = False for item in installed_dir.iterdir(): - # Focus only on non-hidden directories if not item.is_dir() or item.name.startswith("."): continue - # Ignore already-tracked extensions if item.name in self.extensions: continue - # Ignore directories with the wrong naming scheme try: validate_extension_name(item.name) except ValueError: logger.debug(f"Skipping directory with invalid extension name: {item}") + continue - # Try to load the directory as the indicated extension try: extension = installation_interface.load_from_dir(item) except Exception as e: @@ -130,8 +210,9 @@ def discover_untracked( if extension.name != item.name: logger.warning( - "Skipping extension directory because manifest name doesn't match " - f"directory name: dir={item.name!r}, manifest={extension.name!r}" + "Skipping extension directory because manifest name" + " doesn't match directory name:" + f" dir={item.name!r}, manifest={extension.name!r}" ) continue @@ -141,29 +222,6 @@ def discover_untracked( discovered.append(info) self.extensions[item.name] = info - changed = True logger.info(f"Discovered untracked extension: {extension.name}") - return discovered, changed - - def sync_installed( - self, installed_dir: Path, installation_interface: InstallationInterface - ) -> list[InstallationInfo]: - """Reconcile metadata with what is actually on disk. - - Runs ``validate_tracked`` (prunes stale entries) then - ``discover_untracked`` (adds new entries), and persists the metadata - file if either step made changes. - - Returns: - Combined list of valid tracked and newly discovered extensions. - """ - valid_extensions, tracked_changed = self.validate_tracked(installed_dir) - discovered, discovered_changed = self.discover_untracked( - installed_dir, installation_interface - ) - - if tracked_changed or discovered_changed: - self.save_to_dir(installed_dir) - - return valid_extensions + discovered + return discovered diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index 8097ca6f13..ca95da2c5f 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -82,6 +82,51 @@ def test_load_from_dir_invalid_json(tmp_path: Path): assert metadata.extensions == {} +# ============================================================================ +# open() Context Manager Tests +# ============================================================================ + + +def test_open_saves_on_clean_exit(tmp_path: Path): + """Test that the context manager auto-saves on a clean exit.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + info = InstallationInfo( + name="test-ext", + source="local", + install_path=installation_dir / "test-ext", + ) + + with InstallationMetadata.open(installation_dir) as session: + session.extensions["test-ext"] = info + + loaded = InstallationMetadata.load_from_dir(installation_dir) + assert "test-ext" in loaded.extensions + + +def test_open_does_not_save_on_exception(tmp_path: Path): + """Test that the context manager does not save when an exception occurs.""" + installation_dir = tmp_path / "installed" + installation_dir.mkdir() + + info = InstallationInfo( + name="test-ext", + source="local", + install_path=installation_dir / "test-ext", + ) + + try: + with InstallationMetadata.open(installation_dir) as session: + session.extensions["test-ext"] = info + raise RuntimeError("simulated failure") + except RuntimeError: + pass + + loaded = InstallationMetadata.load_from_dir(installation_dir) + assert loaded.extensions == {} + + # ============================================================================ # validate_tracked Tests # ============================================================================ @@ -108,9 +153,8 @@ def test_validate_tracked_prunes_invalid_names(tmp_path: Path): extensions={"Bad_Name": bad_info, "good-ext": good_info} ) - valid, changed = metadata.validate_tracked(installation_dir) + valid = metadata.validate_tracked(installation_dir) - assert changed is True assert len(valid) == 1 assert valid[0].name == "good-ext" assert "Bad_Name" not in metadata.extensions @@ -122,18 +166,16 @@ def test_validate_tracked_prunes_invalid_names(tmp_path: Path): def test_discover_untracked_skips_mismatched_manifest_name(tmp_path: Path): - """Test that discover skips dirs where manifest name doesn't match dir name.""" + """Test that discover skips dirs where manifest name doesn't match.""" installation_dir = tmp_path / "installed" installation_dir.mkdir() - # Create a dir named "some-ext" but manifest says "other-name" _write_mock_extension(installation_dir / "some-ext", name="other-name") metadata = InstallationMetadata() interface = MockExtensionInstallationInterface() - discovered, changed = metadata.discover_untracked(installation_dir, interface) + discovered = metadata.discover_untracked(installation_dir, interface) assert discovered == [] - assert changed is False assert "some-ext" not in metadata.extensions From cb95e2793a809262a6673001ae93202dc2ff538f Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Sat, 11 Apr 2026 14:17:15 -0600 Subject: [PATCH 40/51] minor documentation --- .../openhands/sdk/extensions/installation/README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/README.md b/openhands-sdk/openhands/sdk/extensions/installation/README.md index e466c873aa..3c605fca42 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/README.md +++ b/openhands-sdk/openhands/sdk/extensions/installation/README.md @@ -11,16 +11,6 @@ and an `InstallationInterface[T]` that knows how to load `T` from a directory. Everything else — fetching, copying, metadata bookkeeping, enable/disable state — is handled generically. -### Module layout - -| File | Purpose | -|---|---| -| `interface.py` | `ExtensionProtocol` (structural typing contract) and `InstallationInterface[T]` (abstract loader) | -| `info.py` | `InstallationInfo` — Pydantic model stored per-extension in the metadata file | -| `metadata.py` | `InstallationMetadata` — persistence of `.installed.json`; `MetadataSession` — context manager that auto-saves on exit | -| `manager.py` | `InstallationManager[T]` — high-level API for install / uninstall / enable / disable / update | -| `utils.py` | `validate_extension_name` — kebab-case enforcement to prevent path traversal | - ## Usage ### 1. Define your extension type and loader From 0be0aee2ab54459307c582df80c625322a73216d Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 13 Apr 2026 08:54:14 -0600 Subject: [PATCH 41/51] repalce interface for skills/plugins --- .../sdk/extensions/installation/info.py | 8 +- .../sdk/extensions/installation/interface.py | 17 +- .../openhands/sdk/plugin/__init__.py | 2 - .../openhands/sdk/plugin/installed.py | 534 ++---------------- .../openhands/sdk/skills/__init__.py | 2 - .../openhands/sdk/skills/installed.py | 452 ++------------- tests/sdk/plugin/test_installed_plugins.py | 378 +------------ tests/sdk/skills/test_installed_skills.py | 194 ++----- 8 files changed, 177 insertions(+), 1410 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index 6a3411e3f6..2bd6da5780 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -46,6 +46,10 @@ def from_extension( ) -> InstallationInfo: """Create an InstallationInfo from an extension and its install context. + Only ``extension.name`` is required by ``ExtensionProtocol``. + ``version`` and ``description`` are read with ``getattr`` so + extension types that omit them (e.g. skills) get sensible defaults. + Args: extension: Any object satisfying ``ExtensionProtocol``. source: Original source string (e.g. ``"github:owner/repo"``). @@ -55,8 +59,8 @@ def from_extension( """ return InstallationInfo( name=extension.name, - version=extension.version, - description=extension.description, + version=getattr(extension, "version", "1.0.0"), + description=getattr(extension, "description", None) or "", source=source, resolved_ref=resolved_ref, repo_path=repo_path, diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 2e5b9c5364..45e63e11e0 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -4,16 +4,19 @@ class ExtensionProtocol(Protocol): - """Structural protocol for installable extensions. + """Minimal structural protocol for installable extensions. - Any object with these three attributes can be managed by the - installation system. The fields map directly to - ``InstallationInfo.name``, ``.version``, and ``.description``. + Only ``name`` is required. ``version`` and ``description`` are read + via ``getattr`` in ``InstallationInfo.from_extension`` so that + extension types that don't carry those fields (e.g. skills) still + work without adapter wrappers. + + ``name`` is declared as a read-only property so that both plain + attributes and ``@property`` accessors satisfy the protocol. """ - name: str - version: str - description: str + @property + def name(self) -> str: ... class InstallationInterface[T: ExtensionProtocol](ABC): diff --git a/openhands-sdk/openhands/sdk/plugin/__init__.py b/openhands-sdk/openhands/sdk/plugin/__init__.py index f333039cb0..d5d98d4d84 100644 --- a/openhands-sdk/openhands/sdk/plugin/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/__init__.py @@ -32,7 +32,6 @@ ) from openhands.sdk.plugin.installed import ( InstalledPluginInfo, - InstalledPluginsMetadata, disable_plugin, enable_plugin, get_installed_plugin, @@ -115,7 +114,6 @@ def __getattr__(name: str) -> Any: "resolve_source_path", # Installed plugins management "InstalledPluginInfo", - "InstalledPluginsMetadata", "install_plugin", "uninstall_plugin", "list_installed_plugins", diff --git a/openhands-sdk/openhands/sdk/plugin/installed.py b/openhands-sdk/openhands/sdk/plugin/installed.py index 3d8c935a2d..e306df7348 100644 --- a/openhands-sdk/openhands/sdk/plugin/installed.py +++ b/openhands-sdk/openhands/sdk/plugin/installed.py @@ -1,156 +1,57 @@ """Installed plugins management for OpenHands SDK. -This module provides utilities for managing plugins installed in the user's -home directory (~/.openhands/plugins/installed/). - -The installed plugins directory structure follows the Claude Code pattern:: - - ~/.openhands/plugins/installed/ - ├── plugin-name-1/ - │ ├── .plugin/ - │ │ └── plugin.json - │ ├── skills/ - │ └── ... - ├── plugin-name-2/ - │ └── ... - └── .installed.json # Metadata about installed plugins +Public API for managing plugins installed in the user's home directory. +All heavy lifting is delegated to ``InstallationManager``. """ from __future__ import annotations -import json -import re -import shutil -from datetime import UTC, datetime from pathlib import Path -from pydantic import BaseModel, Field - -from openhands.sdk.logger import get_logger -from openhands.sdk.plugin.fetch import ( - fetch_plugin_with_resolution, +from openhands.sdk.extensions.installation import ( + InstallationInfo, + InstallationInterface, + InstallationManager, ) from openhands.sdk.plugin.plugin import Plugin -logger = get_logger(__name__) +# Public type alias — keeps existing import sites working. +InstalledPluginInfo = InstallationInfo -# Default directory for installed plugins DEFAULT_INSTALLED_PLUGINS_DIR = Path.home() / ".openhands" / "plugins" / "installed" -# Metadata file for tracking installed plugins -_METADATA_FILENAME = ".installed.json" - -_PLUGIN_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def _resolve_installed_dir(installed_dir: Path | None) -> Path: - """Return installed_dir or the default if None.""" - return installed_dir if installed_dir is not None else DEFAULT_INSTALLED_PLUGINS_DIR - def get_installed_plugins_dir() -> Path: - """Get the default directory for installed plugins. - - Returns: - Path to ~/.openhands/plugins/installed/ - """ + """Get the default directory for installed plugins.""" return DEFAULT_INSTALLED_PLUGINS_DIR -def _validate_plugin_name(name: str) -> None: - """Validate plugin name is Claude-like kebab-case. +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- - This protects filesystem operations (install/uninstall) from path traversal. - """ - if not _PLUGIN_NAME_PATTERN.fullmatch(name): - raise ValueError( - f"Invalid plugin name. Expected kebab-case like 'my-plugin' (got {name!r})." - ) +class PluginInstallationInterface(InstallationInterface[Plugin]): + @staticmethod + def load_from_dir(extension_dir: Path) -> Plugin: + return Plugin.load(extension_dir) -class InstalledPluginInfo(BaseModel): - """Information about an installed plugin. - This model tracks metadata about a plugin installation, including - where it was installed from and when. - """ +def _resolve_installed_dir(installed_dir: Path | None) -> Path: + return installed_dir if installed_dir is not None else DEFAULT_INSTALLED_PLUGINS_DIR - name: str = Field(description="Plugin name (from manifest)") - version: str = Field(default="1.0.0", description="Plugin version") - description: str = Field(default="", description="Plugin description") - enabled: bool = Field(default=True, description="Whether the plugin is enabled") - source: str = Field(description="Original source (e.g., 'github:owner/repo')") - resolved_ref: str | None = Field( - default=None, - description="Resolved git commit SHA (for version pinning)", - ) - repo_path: str | None = Field( - default=None, - description="Subdirectory path within the repository (for monorepos)", - ) - installed_at: str = Field( - description="ISO 8601 timestamp of installation", - ) - install_path: str = Field( - description="Path where the plugin is installed", - ) - @classmethod - def from_plugin( - cls, - plugin: Plugin, - source: str, - resolved_ref: str | None, - repo_path: str | None, - install_path: Path, - ) -> InstalledPluginInfo: - """Create InstalledPluginInfo from a loaded Plugin.""" - return cls( - name=plugin.name, - version=plugin.version, - description=plugin.description, - source=source, - resolved_ref=resolved_ref, - repo_path=repo_path, - installed_at=datetime.now(UTC).isoformat(), - install_path=str(install_path), - ) - - -class InstalledPluginsMetadata(BaseModel): - """Metadata file for tracking all installed plugins.""" - - plugins: dict[str, InstalledPluginInfo] = Field( - default_factory=dict, - description="Map of plugin name to installation info", +def _manager(installed_dir: Path) -> InstallationManager[Plugin]: + return InstallationManager( + installation_dir=installed_dir, + installation_interface=PluginInstallationInterface(), ) - @classmethod - def get_path(cls, installed_dir: Path) -> Path: - """Get the metadata file path for the given installed plugins directory.""" - return installed_dir / _METADATA_FILENAME - - @classmethod - def load_from_dir(cls, installed_dir: Path) -> InstalledPluginsMetadata: - """Load metadata from the installed plugins directory.""" - metadata_path = cls.get_path(installed_dir) - if not metadata_path.exists(): - return cls() - try: - with open(metadata_path) as f: - data = json.load(f) - return cls.model_validate(data) - except Exception as e: - logger.warning(f"Failed to load installed plugins metadata: {e}") - return cls() - - def save_to_dir(self, installed_dir: Path) -> None: - """Save metadata to the installed plugins directory.""" - metadata_path = self.get_path(installed_dir) - metadata_path.parent.mkdir(parents=True, exist_ok=True) - with open(metadata_path, "w") as f: - json.dump(self.model_dump(), f, indent=2) + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- def install_plugin( @@ -162,87 +63,22 @@ def install_plugin( ) -> InstalledPluginInfo: """Install a plugin from a source. - Fetches the plugin from the source, copies it to the installed plugins - directory, and records the installation metadata. - Args: - source: Plugin source - can be: - - "github:owner/repo" - GitHub shorthand - - Any git URL (GitHub, GitLab, Bitbucket, etc.) - - Local path (for development/testing) + source: Plugin source — ``"github:owner/repo"``, git URL, or + local path. ref: Optional branch, tag, or commit to install. repo_path: Subdirectory path within the repository (for monorepos). installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - force: If True, overwrite existing installation. If False, raise error - if plugin is already installed. + Defaults to ``~/.openhands/plugins/installed/``. + force: If True, overwrite existing installation. Returns: InstalledPluginInfo with details about the installation. - - Raises: - PluginFetchError: If fetching the plugin fails. - FileExistsError: If plugin is already installed and force=False. - ValueError: If the plugin manifest is invalid. - - Example: - >>> info = install_plugin("github:owner/my-plugin", ref="v1.0.0") - >>> print(f"Installed {info.name} from {info.source}") """ - installed_dir = _resolve_installed_dir(installed_dir) - - # Fetch the plugin (downloads to cache if remote) - logger.info(f"Fetching plugin from {source}") - fetched_path, resolved_ref = fetch_plugin_with_resolution( - source=source, - ref=ref, - repo_path=repo_path, - update=True, - ) - - # Load the plugin to get its metadata - plugin = Plugin.load(fetched_path) - plugin_name = plugin.name - _validate_plugin_name(plugin_name) - - # Check if already installed - install_path = installed_dir / plugin_name - if install_path.exists() and not force: - raise FileExistsError( - f"Plugin '{plugin_name}' is already installed at {install_path}. " - f"Use force=True to overwrite." - ) - - # Remove existing installation if force=True - if install_path.exists(): - logger.info(f"Removing existing installation of '{plugin_name}'") - shutil.rmtree(install_path) - - # Copy plugin to installed directory - logger.info(f"Installing plugin '{plugin_name}' to {install_path}") - installed_dir.mkdir(parents=True, exist_ok=True) - shutil.copytree(fetched_path, install_path) - - # Create installation info - info = InstalledPluginInfo.from_plugin( - plugin=plugin, - source=source, - resolved_ref=resolved_ref, - repo_path=repo_path, - install_path=install_path, + return _manager(_resolve_installed_dir(installed_dir)).install( + source, ref=ref, repo_path=repo_path, force=force ) - # Update metadata - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - existing_info = metadata.plugins.get(plugin_name) - if existing_info is not None: - info.enabled = existing_info.enabled - metadata.plugins[plugin_name] = info - metadata.save_to_dir(installed_dir) - - logger.info(f"Successfully installed plugin '{plugin_name}' v{plugin.version}") - return info - def uninstall_plugin( name: str, @@ -250,82 +86,10 @@ def uninstall_plugin( ) -> bool: """Uninstall a plugin by name. - Only plugins tracked in the installed plugins metadata file can be uninstalled. - This avoids deleting arbitrary directories in the installed plugins directory. - - Args: - name: Name of the plugin to uninstall. - installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - Returns: True if the plugin was uninstalled, False if it wasn't installed. - - Example: - >>> if uninstall_plugin("my-plugin"): - ... print("Plugin uninstalled") - ... else: - ... print("Plugin was not installed") """ - _validate_plugin_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - if name not in metadata.plugins: - logger.warning(f"Plugin '{name}' is not installed") - return False - - plugin_path = installed_dir / name - if plugin_path.exists(): - logger.info(f"Uninstalling plugin '{name}' from {plugin_path}") - shutil.rmtree(plugin_path) - else: - logger.warning( - f"Plugin '{name}' was tracked but its directory is missing: {plugin_path}" - ) - - del metadata.plugins[name] - metadata.save_to_dir(installed_dir) - - logger.info(f"Successfully uninstalled plugin '{name}'") - return True - - -def _set_plugin_enabled( - name: str, - enabled: bool, - installed_dir: Path | None = None, -) -> bool: - _validate_plugin_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - logger.warning(f"Installed plugins directory does not exist: {installed_dir}") - return False - - metadata, _ = _sync_installed_plugins_metadata(installed_dir) - info = metadata.plugins.get(name) - if info is None: - logger.warning(f"Plugin '{name}' is not installed") - return False - - plugin_path = installed_dir / name - if not plugin_path.exists(): - logger.warning( - f"Plugin '{name}' was tracked but its directory is missing: {plugin_path}" - ) - return False - - if info.enabled == enabled: - return True - - info.enabled = enabled - metadata.plugins[name] = info - metadata.save_to_dir(installed_dir) - - state = "enabled" if enabled else "disabled" - logger.info(f"Successfully {state} plugin '{name}'") - return True + return _manager(_resolve_installed_dir(installed_dir)).uninstall(name) def enable_plugin( @@ -333,7 +97,7 @@ def enable_plugin( installed_dir: Path | None = None, ) -> bool: """Enable an installed plugin by name.""" - return _set_plugin_enabled(name, True, installed_dir) + return _manager(_resolve_installed_dir(installed_dir)).enable(name) def disable_plugin( @@ -341,106 +105,7 @@ def disable_plugin( installed_dir: Path | None = None, ) -> bool: """Disable an installed plugin by name.""" - return _set_plugin_enabled(name, False, installed_dir) - - -def _validate_tracked_plugins( - metadata: InstalledPluginsMetadata, installed_dir: Path -) -> tuple[list[InstalledPluginInfo], bool]: - """Validate tracked plugins exist on disk. - - Returns: - Tuple of (valid plugins list, whether metadata was modified). - """ - valid_plugins: list[InstalledPluginInfo] = [] - changed = False - - for name, info in list(metadata.plugins.items()): - try: - _validate_plugin_name(name) - except ValueError as e: - logger.warning(f"Invalid tracked plugin name {name!r}, removing: {e}") - del metadata.plugins[name] - changed = True - continue - - plugin_path = installed_dir / name - if plugin_path.exists(): - valid_plugins.append(info) - else: - logger.warning(f"Plugin '{name}' directory missing, removing from metadata") - del metadata.plugins[name] - changed = True - - return valid_plugins, changed - - -def _discover_untracked_plugins( - metadata: InstalledPluginsMetadata, installed_dir: Path -) -> tuple[list[InstalledPluginInfo], bool]: - """Discover plugin directories not tracked in metadata. - - Returns: - Tuple of (discovered plugins list, whether metadata was modified). - """ - discovered: list[InstalledPluginInfo] = [] - changed = False - - for item in installed_dir.iterdir(): - if not item.is_dir() or item.name.startswith("."): - continue - if item.name in metadata.plugins: - continue - - try: - _validate_plugin_name(item.name) - except ValueError: - logger.debug(f"Skipping directory with invalid plugin name: {item}") - continue - - try: - plugin = Plugin.load(item) - except Exception as e: - logger.debug(f"Skipping directory {item}: {e}") - continue - - if plugin.name != item.name: - logger.warning( - "Skipping plugin directory because manifest name doesn't match " - f"directory name: dir={item.name!r}, manifest={plugin.name!r}" - ) - continue - - info = InstalledPluginInfo( - name=plugin.name, - version=plugin.version, - description=plugin.description, - source="local", - installed_at=datetime.now(UTC).isoformat(), - install_path=str(item), - ) - discovered.append(info) - metadata.plugins[item.name] = info - changed = True - logger.info(f"Discovered untracked plugin: {plugin.name}") - - return discovered, changed - - -def _sync_installed_plugins_metadata( - installed_dir: Path, -) -> tuple[InstalledPluginsMetadata, list[InstalledPluginInfo]]: - """Sync installed plugins metadata with on-disk plugin directories.""" - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - valid_plugins, tracked_changed = _validate_tracked_plugins(metadata, installed_dir) - discovered, discovered_changed = _discover_untracked_plugins( - metadata, installed_dir - ) - - if tracked_changed or discovered_changed: - metadata.save_to_dir(installed_dir) - - return metadata, valid_plugins + discovered + return _manager(_resolve_installed_dir(installed_dir)).disable(name) def list_installed_plugins( @@ -448,142 +113,29 @@ def list_installed_plugins( ) -> list[InstalledPluginInfo]: """List all installed plugins. - This function is self-healing: it may update the installed plugins metadata - file to remove entries whose directories were deleted, and to add entries for - plugin directories that were manually copied into the installed dir. - - Args: - installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - - Returns: - List of InstalledPluginInfo for each installed plugin. - - Example: - >>> for info in list_installed_plugins(): - ... print(f"{info.name} v{info.version} - {info.description}") + Self-healing: reconciles metadata with what is on disk. """ - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - return [] - - _, plugins = _sync_installed_plugins_metadata(installed_dir) - return plugins + return _manager(_resolve_installed_dir(installed_dir)).list_installed() def load_installed_plugins( installed_dir: Path | None = None, ) -> list[Plugin]: - """Load all installed plugins. - - Loads Plugin objects for all plugins in the installed plugins directory. - This is useful for integrating installed plugins into an agent. - - Args: - installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - - Returns: - List of loaded Plugin objects. - - Example: - >>> plugins = load_installed_plugins() - >>> for plugin in plugins: - ... print(f"Loaded {plugin.name} with {len(plugin.skills)} skills") - """ - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - return [] - - installed_infos = list_installed_plugins(installed_dir) - plugins: list[Plugin] = [] - for info in installed_infos: - if not info.enabled: - continue - plugin_path = installed_dir / info.name - if plugin_path.exists(): - plugins.append(Plugin.load(plugin_path)) - return plugins + """Load all enabled installed plugins as ``Plugin`` objects.""" + return _manager(_resolve_installed_dir(installed_dir)).load_installed() def get_installed_plugin( name: str, installed_dir: Path | None = None, ) -> InstalledPluginInfo | None: - """Get information about a specific installed plugin. - - Args: - name: Name of the plugin to look up. - installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - - Returns: - InstalledPluginInfo if the plugin is installed, None otherwise. - - Example: - >>> info = get_installed_plugin("my-plugin") - >>> if info: - ... print(f"Installed from {info.source} at {info.installed_at}") - """ - _validate_plugin_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - info = metadata.plugins.get(name) - - # Verify the plugin directory still exists - if info is not None: - plugin_path = installed_dir / name - if not plugin_path.exists(): - return None - - return info + """Get information about a specific installed plugin.""" + return _manager(_resolve_installed_dir(installed_dir)).get(name) def update_plugin( name: str, installed_dir: Path | None = None, ) -> InstalledPluginInfo | None: - """Update an installed plugin to the latest version. - - Re-fetches the plugin from its original source and reinstalls it. - - This always updates to the latest version available from the original source - (i.e., it does not preserve a pinned ref). - - Args: - name: Name of the plugin to update. - installed_dir: Directory for installed plugins. - Defaults to ~/.openhands/plugins/installed/ - - Returns: - Updated InstalledPluginInfo if successful, None if plugin not installed. - - Raises: - PluginFetchError: If fetching the updated plugin fails. - - Example: - >>> info = update_plugin("my-plugin") - >>> if info: - ... print(f"Updated to v{info.version}") - """ - _validate_plugin_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - # Get current installation info - current_info = get_installed_plugin(name, installed_dir) - if current_info is None: - logger.warning(f"Plugin '{name}' is not installed") - return None - - # Re-install from the original source - logger.info(f"Updating plugin '{name}' from {current_info.source}") - return install_plugin( - source=current_info.source, - ref=None, # Get latest (don't use pinned ref) - repo_path=current_info.repo_path, - installed_dir=installed_dir, - force=True, - ) + """Update an installed plugin to the latest version.""" + return _manager(_resolve_installed_dir(installed_dir)).update(name) diff --git a/openhands-sdk/openhands/sdk/skills/__init__.py b/openhands-sdk/openhands/sdk/skills/__init__.py index 66e9645324..62a47f09dc 100644 --- a/openhands-sdk/openhands/sdk/skills/__init__.py +++ b/openhands-sdk/openhands/sdk/skills/__init__.py @@ -41,7 +41,6 @@ # Installed skills management from openhands.sdk.skills.installed import ( InstalledSkillInfo, - InstalledSkillsMetadata, disable_skill, enable_skill, get_installed_skill, @@ -99,7 +98,6 @@ "fetch_skill_with_resolution", # Installed skills management "InstalledSkillInfo", - "InstalledSkillsMetadata", "install_skill", "install_skills_from_marketplace", "uninstall_skill", diff --git a/openhands-sdk/openhands/sdk/skills/installed.py b/openhands-sdk/openhands/sdk/skills/installed.py index 660499a1bf..7888448284 100644 --- a/openhands-sdk/openhands/sdk/skills/installed.py +++ b/openhands-sdk/openhands/sdk/skills/installed.py @@ -1,50 +1,40 @@ """Installed skills management for OpenHands SDK. -This module provides utilities for managing AgentSkills installed in the user's -home directory (~/.openhands/skills/installed/). +Public API for managing AgentSkills installed in the user's home directory. +All heavy lifting is delegated to ``InstallationManager``. """ from __future__ import annotations -import json -import shutil -from datetime import UTC, datetime from pathlib import Path -from pydantic import BaseModel, Field - +from openhands.sdk.extensions.installation import ( + InstallationInfo, + InstallationInterface, + InstallationManager, +) from openhands.sdk.logger import get_logger -from openhands.sdk.skills.exceptions import SkillError, SkillValidationError -from openhands.sdk.skills.fetch import fetch_skill_with_resolution -from openhands.sdk.skills.skill import Skill, load_skills_from_dir -from openhands.sdk.skills.utils import find_skill_md, validate_skill_name +from openhands.sdk.skills.exceptions import SkillValidationError +from openhands.sdk.skills.skill import Skill +from openhands.sdk.skills.utils import find_skill_md logger = get_logger(__name__) -DEFAULT_INSTALLED_SKILLS_DIR = Path.home() / ".openhands" / "skills" / "installed" -_METADATA_FILENAME = ".installed.json" - +# Public type alias — keeps existing import sites working. +InstalledSkillInfo = InstallationInfo -def _resolve_installed_dir(installed_dir: Path | None) -> Path: - """Return installed_dir or the default if None.""" - return installed_dir if installed_dir is not None else DEFAULT_INSTALLED_SKILLS_DIR +DEFAULT_INSTALLED_SKILLS_DIR = Path.home() / ".openhands" / "skills" / "installed" def get_installed_skills_dir() -> Path: - """Get the default directory for installed skills. - - Returns: - Path to ~/.openhands/skills/installed/ - """ + """Get the default directory for installed skills.""" return DEFAULT_INSTALLED_SKILLS_DIR -def _validate_skill_name(name: str) -> None: - """Validate skill name according to AgentSkills spec.""" - errors = validate_skill_name(name) - if errors: - raise ValueError(f"Invalid skill name {name!r}: {'; '.join(errors)}") +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- def _load_skill_from_dir(skill_root: Path) -> Skill: @@ -55,92 +45,26 @@ def _load_skill_from_dir(skill_root: Path) -> Skill: return Skill.load(skill_md, strict=True) -class InstalledSkillInfo(BaseModel): - """Information about an installed skill.""" +class SkillInstallationInterface(InstallationInterface[Skill]): + @staticmethod + def load_from_dir(extension_dir: Path) -> Skill: + return _load_skill_from_dir(extension_dir) - name: str = Field(description="Skill name") - description: str = Field(default="", description="Skill description") - license: str | None = Field(default=None, description="Skill license") - compatibility: str | None = Field( - default=None, description="Compatibility notes for the skill" - ) - metadata: dict[str, str] | None = Field( - default=None, description="Additional skill metadata" - ) - allowed_tools: list[str] | None = Field( - default=None, description="Allowed tools list for the skill" - ) - enabled: bool = Field(default=True, description="Whether the skill is enabled") - source: str = Field(description="Original source (e.g., 'github:owner/repo')") - resolved_ref: str | None = Field( - default=None, - description="Resolved git commit SHA (for version pinning)", - ) - repo_path: str | None = Field( - default=None, - description="Subdirectory path within the repository (for monorepos)", - ) - installed_at: str = Field(description="ISO 8601 timestamp of installation") - install_path: str = Field(description="Path where the skill is installed") - - @classmethod - def from_skill( - cls, - skill: Skill, - source: str, - resolved_ref: str | None, - repo_path: str | None, - install_path: Path, - ) -> InstalledSkillInfo: - """Create InstalledSkillInfo from a loaded Skill.""" - return cls( - name=skill.name, - description=skill.description or "", - license=skill.license, - compatibility=skill.compatibility, - metadata=skill.metadata, - allowed_tools=skill.allowed_tools, - source=source, - resolved_ref=resolved_ref, - repo_path=repo_path, - installed_at=datetime.now(UTC).isoformat(), - install_path=str(install_path), - ) +def _resolve_installed_dir(installed_dir: Path | None) -> Path: + return installed_dir if installed_dir is not None else DEFAULT_INSTALLED_SKILLS_DIR -class InstalledSkillsMetadata(BaseModel): - """Metadata file for tracking installed skills.""" - skills: dict[str, InstalledSkillInfo] = Field( - default_factory=dict, - description="Map of skill name to installation info", +def _manager(installed_dir: Path) -> InstallationManager[Skill]: + return InstallationManager( + installation_dir=installed_dir, + installation_interface=SkillInstallationInterface(), ) - @classmethod - def get_path(cls, installed_dir: Path) -> Path: - """Get the metadata file path for the given installed skills directory.""" - return installed_dir / _METADATA_FILENAME - - @classmethod - def load_from_dir(cls, installed_dir: Path) -> InstalledSkillsMetadata: - """Load metadata from the installed skills directory.""" - metadata_path = cls.get_path(installed_dir) - if not metadata_path.exists(): - return cls() - try: - with open(metadata_path) as f: - data = json.load(f) - return cls.model_validate(data) - except Exception as e: - logger.warning(f"Failed to load installed skills metadata: {e}") - return cls() - def save_to_dir(self, installed_dir: Path) -> None: - """Save metadata to the installed skills directory.""" - metadata_path = self.get_path(installed_dir) - metadata_path.parent.mkdir(parents=True, exist_ok=True) - with open(metadata_path, "w") as f: - json.dump(self.model_dump(), f, indent=2) +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- def install_skill( @@ -153,69 +77,20 @@ def install_skill( """Install a skill from a source. Args: - source: Skill source - git URL, GitHub shorthand, or local path. + source: Skill source — git URL, GitHub shorthand, or local path. ref: Optional branch, tag, or commit to install. repo_path: Subdirectory path within the repository (for monorepos). installed_dir: Directory for installed skills. - Defaults to ~/.openhands/skills/installed/ - force: If True, overwrite existing installation. If False, raise error - if the skill is already installed. + Defaults to ``~/.openhands/skills/installed/``. + force: If True, overwrite existing installation. Returns: InstalledSkillInfo with details about the installation. - - Raises: - SkillFetchError: If fetching the skill fails. - FileExistsError: If skill is already installed and force=False. - SkillValidationError: If the skill metadata is invalid. """ - installed_dir = _resolve_installed_dir(installed_dir) - - logger.info(f"Fetching skill from {source}") - fetched_path, resolved_ref = fetch_skill_with_resolution( - source=source, - ref=ref, - repo_path=repo_path, - update=True, - ) - - skill = _load_skill_from_dir(fetched_path) - skill_name = skill.name - _validate_skill_name(skill_name) - - install_path = installed_dir / skill_name - if install_path.exists() and not force: - raise FileExistsError( - f"Skill '{skill_name}' is already installed at {install_path}. " - "Use force=True to overwrite." - ) - - if install_path.exists(): - logger.info(f"Removing existing installation of '{skill_name}'") - shutil.rmtree(install_path) - - logger.info(f"Installing skill '{skill_name}' to {install_path}") - installed_dir.mkdir(parents=True, exist_ok=True) - shutil.copytree(fetched_path, install_path) - - info = InstalledSkillInfo.from_skill( - skill=skill, - source=source, - resolved_ref=resolved_ref, - repo_path=repo_path, - install_path=install_path, + return _manager(_resolve_installed_dir(installed_dir)).install( + source, ref=ref, repo_path=repo_path, force=force ) - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - existing_info = metadata.skills.get(skill_name) - if existing_info is not None: - info.enabled = existing_info.enabled - metadata.skills[skill_name] = info - metadata.save_to_dir(installed_dir) - - logger.info(f"Successfully installed skill '{skill_name}'") - return info - def uninstall_skill( name: str, @@ -223,76 +98,10 @@ def uninstall_skill( ) -> bool: """Uninstall a skill by name. - Only skills tracked in the installed skills metadata file can be uninstalled. - - Args: - name: Name of the skill to uninstall. - installed_dir: Directory for installed skills. - Defaults to ~/.openhands/skills/installed/ - Returns: True if the skill was uninstalled, False if it wasn't installed. """ - _validate_skill_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - if name not in metadata.skills: - logger.warning(f"Skill '{name}' is not installed") - return False - - skill_path = installed_dir / name - if skill_path.exists(): - logger.info(f"Uninstalling skill '{name}' from {skill_path}") - shutil.rmtree(skill_path) - else: - logger.warning( - f"Skill '{name}' was tracked but its directory is missing: {skill_path}" - ) - - del metadata.skills[name] - metadata.save_to_dir(installed_dir) - - logger.info(f"Successfully uninstalled skill '{name}'") - return True - - -def _set_skill_enabled( - name: str, - enabled: bool, - installed_dir: Path | None = None, -) -> bool: - _validate_skill_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - logger.warning(f"Installed skills directory does not exist: {installed_dir}") - return False - - list_installed_skills(installed_dir) - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - info = metadata.skills.get(name) - if info is None: - logger.warning(f"Skill '{name}' is not installed") - return False - - skill_path = installed_dir / name - if not skill_path.exists(): - logger.warning( - f"Skill '{name}' was tracked but its directory is missing: {skill_path}" - ) - return False - - if info.enabled == enabled: - return True - - info.enabled = enabled - metadata.skills[name] = info - metadata.save_to_dir(installed_dir) - - state = "enabled" if enabled else "disabled" - logger.info(f"Successfully {state} skill '{name}'") - return True + return _manager(_resolve_installed_dir(installed_dir)).uninstall(name) def enable_skill( @@ -300,7 +109,7 @@ def enable_skill( installed_dir: Path | None = None, ) -> bool: """Enable an installed skill by name.""" - return _set_skill_enabled(name, True, installed_dir) + return _manager(_resolve_installed_dir(installed_dir)).enable(name) def disable_skill( @@ -308,85 +117,7 @@ def disable_skill( installed_dir: Path | None = None, ) -> bool: """Disable an installed skill by name.""" - return _set_skill_enabled(name, False, installed_dir) - - -def _validate_tracked_skills( - metadata: InstalledSkillsMetadata, installed_dir: Path -) -> tuple[list[InstalledSkillInfo], bool]: - """Validate tracked skills exist on disk.""" - valid_skills: list[InstalledSkillInfo] = [] - changed = False - - for name, info in list(metadata.skills.items()): - try: - _validate_skill_name(name) - except ValueError as e: - logger.warning(f"Invalid tracked skill name {name!r}, removing: {e}") - del metadata.skills[name] - changed = True - continue - - skill_path = installed_dir / name - if skill_path.exists(): - valid_skills.append(info) - else: - logger.warning(f"Skill '{name}' directory missing, removing from metadata") - del metadata.skills[name] - changed = True - - return valid_skills, changed - - -def _discover_untracked_skills( - metadata: InstalledSkillsMetadata, installed_dir: Path -) -> tuple[list[InstalledSkillInfo], bool]: - """Discover skill directories not tracked in metadata.""" - discovered: list[InstalledSkillInfo] = [] - changed = False - - for item in installed_dir.iterdir(): - if not item.is_dir() or item.name.startswith("."): - continue - if item.name in metadata.skills: - continue - - try: - _validate_skill_name(item.name) - except ValueError: - logger.debug(f"Skipping directory with invalid skill name: {item}") - continue - - try: - skill = _load_skill_from_dir(item) - except (SkillError, OSError) as e: - logger.debug(f"Skipping directory {item}: {e}") - continue - - if skill.name != item.name: - logger.warning( - "Skipping skill directory because name doesn't match directory: " - f"dir={item.name!r}, skill={skill.name!r}" - ) - continue - - info = InstalledSkillInfo( - name=skill.name, - description=skill.description or "", - license=skill.license, - compatibility=skill.compatibility, - metadata=skill.metadata, - allowed_tools=skill.allowed_tools, - source="local", - installed_at=datetime.now(UTC).isoformat(), - install_path=str(item), - ) - discovered.append(info) - metadata.skills[item.name] = info - changed = True - logger.info(f"Discovered untracked skill: {skill.name}") - - return discovered, changed + return _manager(_resolve_installed_dir(installed_dir)).disable(name) def list_installed_skills( @@ -394,56 +125,16 @@ def list_installed_skills( ) -> list[InstalledSkillInfo]: """List all installed skills. - This function is self-healing: it may update the installed skills metadata - file to remove entries whose directories were deleted, and to add entries for - skill directories that were manually copied into the installed dir. - - Args: - installed_dir: Directory for installed skills. - Defaults to ~/.openhands/skills/installed/ - - Returns: - List of InstalledSkillInfo for each installed skill. + Self-healing: reconciles metadata with what is on disk. """ - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - return [] - - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - - valid_skills, tracked_changed = _validate_tracked_skills(metadata, installed_dir) - discovered, discovered_changed = _discover_untracked_skills(metadata, installed_dir) - - if tracked_changed or discovered_changed: - metadata.save_to_dir(installed_dir) - - return valid_skills + discovered + return _manager(_resolve_installed_dir(installed_dir)).list_installed() def load_installed_skills( installed_dir: Path | None = None, ) -> list[Skill]: - """Load all installed skills. - - Args: - installed_dir: Directory for installed skills. - Defaults to ~/.openhands/skills/installed/ - - Returns: - List of loaded Skill objects. - """ - installed_dir = _resolve_installed_dir(installed_dir) - - if not installed_dir.exists(): - return [] - - installed_infos = list_installed_skills(installed_dir) - enabled_names = {info.name for info in installed_infos if info.enabled} - - repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(installed_dir) - all_skills = {**repo_skills, **knowledge_skills, **agent_skills} - return [skill for name, skill in all_skills.items() if name in enabled_names] + """Load all enabled installed skills as ``Skill`` objects.""" + return _manager(_resolve_installed_dir(installed_dir)).load_installed() def get_installed_skill( @@ -451,18 +142,7 @@ def get_installed_skill( installed_dir: Path | None = None, ) -> InstalledSkillInfo | None: """Get information about a specific installed skill.""" - _validate_skill_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - info = metadata.skills.get(name) - - if info is not None: - skill_path = installed_dir / name - if not skill_path.exists(): - return None - - return info + return _manager(_resolve_installed_dir(installed_dir)).get(name) def update_skill( @@ -470,22 +150,7 @@ def update_skill( installed_dir: Path | None = None, ) -> InstalledSkillInfo | None: """Update an installed skill to the latest version.""" - _validate_skill_name(name) - installed_dir = _resolve_installed_dir(installed_dir) - - current_info = get_installed_skill(name, installed_dir) - if current_info is None: - logger.warning(f"Skill '{name}' is not installed") - return None - - logger.info(f"Updating skill '{name}' from {current_info.source}") - return install_skill( - source=current_info.source, - ref=None, - repo_path=current_info.repo_path, - installed_dir=installed_dir, - force=True, - ) + return _manager(_resolve_installed_dir(installed_dir)).update(name) def install_skills_from_marketplace( @@ -495,28 +160,15 @@ def install_skills_from_marketplace( ) -> list[InstalledSkillInfo]: """Install all skills defined in a marketplace.json file. - This function reads the marketplace.json, resolves each skill source - (supporting both local paths and GitHub URLs), and installs them to - the installed skills directory. - Args: - marketplace_path: Path to the directory containing .plugin/marketplace.json + marketplace_path: Path to the directory containing + ``.plugin/marketplace.json``. installed_dir: Directory for installed skills. - Defaults to ~/.openhands/skills/installed/ + Defaults to ``~/.openhands/skills/installed/``. force: If True, overwrite existing installations. Returns: List of InstalledSkillInfo for successfully installed skills. - - Raises: - FileNotFoundError: If the marketplace.json doesn't exist. - ValueError: If the marketplace.json is invalid. - - Example: - >>> # Install all skills from a marketplace - >>> installed = install_skills_from_marketplace("./my-marketplace") - >>> for info in installed: - ... print(f"Installed: {info.name}") """ from openhands.sdk.marketplace import Marketplace from openhands.sdk.plugin import resolve_source_path @@ -524,15 +176,11 @@ def install_skills_from_marketplace( marketplace_path = Path(marketplace_path) installed_dir = _resolve_installed_dir(installed_dir) - # Load the marketplace marketplace = Marketplace.load(marketplace_path) - installed: list[InstalledSkillInfo] = [] - # Collect skill directories: standalone skills + skills from plugins - skill_dirs: list[tuple[str, Path]] = [] # (name, path) + skill_dirs: list[tuple[str, Path]] = [] - # 1. Standalone skills from marketplace.skills for entry in marketplace.skills: resolved = resolve_source_path( entry.source, base_path=marketplace_path, update=True @@ -542,7 +190,6 @@ def install_skills_from_marketplace( else: logger.warning(f"Failed to resolve skill '{entry.name}'") - # 2. Skills from plugins (each plugin's skills/ directory) for plugin in marketplace.plugins: if isinstance(plugin.source, str): source = plugin.source @@ -559,19 +206,16 @@ def install_skills_from_marketplace( logger.warning(f"Failed to resolve plugin '{plugin.name}'") continue - # Find skills/ directory in plugin skills_dir = resolved / "skills" if not skills_dir.exists(): continue - # Each subdirectory in skills/ is a skill for skill_path in skills_dir.iterdir(): if skill_path.is_dir() and (skill_path / "SKILL.md").exists(): skill_dirs.append((skill_path.name, skill_path)) logger.info(f"Found {len(skill_dirs)} skills to install from marketplace") - # Install all collected skills for name, path in skill_dirs: try: info = install_skill(str(path), installed_dir=installed_dir, force=force) diff --git a/tests/sdk/plugin/test_installed_plugins.py b/tests/sdk/plugin/test_installed_plugins.py index a9dc3c00a9..5f98d65434 100644 --- a/tests/sdk/plugin/test_installed_plugins.py +++ b/tests/sdk/plugin/test_installed_plugins.py @@ -1,23 +1,20 @@ """Tests for installed plugins management. -This module contains both unit tests and integration tests for plugin -installation, management, and lifecycle operations. +These tests verify the public API in ``openhands.sdk.plugin.installed`` +delegates correctly to ``InstallationManager``. Internal metadata and +sync logic is already covered by ``tests/sdk/extensions/installation/``. -Unit tests use mocks for external operations (GitHub fetch). -Integration tests (marked with @pytest.mark.network) test real GitHub cloning. +Integration tests (marked with @pytest.mark.network) test real GitHub +cloning and remain unchanged. """ import json -import shutil from pathlib import Path -from unittest.mock import patch import pytest from openhands.sdk.extensions.fetch import get_cache_path, parse_extension_source from openhands.sdk.plugin import ( - InstalledPluginInfo, - InstalledPluginsMetadata, Plugin, PluginFetchError, disable_plugin, @@ -40,7 +37,6 @@ @pytest.fixture def installed_dir(tmp_path: Path) -> Path: - """Create a temporary installed plugins directory.""" installed = tmp_path / "installed" installed.mkdir(parents=True) return installed @@ -48,11 +44,9 @@ def installed_dir(tmp_path: Path) -> Path: @pytest.fixture def sample_plugin_dir(tmp_path: Path) -> Path: - """Create a sample plugin directory structure.""" plugin_dir = tmp_path / "sample-plugin" plugin_dir.mkdir(parents=True) - # Create plugin manifest manifest_dir = plugin_dir / ".plugin" manifest_dir.mkdir() manifest = { @@ -62,140 +56,41 @@ def sample_plugin_dir(tmp_path: Path) -> Path: } (manifest_dir / "plugin.json").write_text(json.dumps(manifest)) - # Create a skill skills_dir = plugin_dir / "skills" / "test-skill" skills_dir.mkdir(parents=True) - skill_content = """--- -name: test-skill -description: A test skill -triggers: - - test ---- -# Test Skill - -This is a test skill. -""" - (skills_dir / "SKILL.md").write_text(skill_content) + (skills_dir / "SKILL.md").write_text( + "---\nname: test-skill\ndescription: A test skill\n" + "triggers:\n - test\n---\n# Test Skill\n" + ) return plugin_dir # ============================================================================ -# Model Tests -# ============================================================================ - - -class TestInstalledPluginInfo: - """Tests for InstalledPluginInfo model.""" - - def test_from_plugin(self, sample_plugin_dir: Path, tmp_path: Path): - """Test creating InstalledPluginInfo from a Plugin.""" - plugin = Plugin.load(sample_plugin_dir) - install_path = tmp_path / "installed" / "sample-plugin" - - info = InstalledPluginInfo.from_plugin( - plugin=plugin, - source="github:owner/sample-plugin", - resolved_ref="abc123", - repo_path=None, - install_path=install_path, - ) - - assert info.name == "sample-plugin" - assert info.version == "1.0.0" - assert info.description == "A sample plugin for testing" - assert info.source == "github:owner/sample-plugin" - assert info.resolved_ref == "abc123" - assert info.repo_path is None - assert info.installed_at is not None - assert str(install_path) in info.install_path - - -class TestInstalledPluginsMetadata: - """Tests for InstalledPluginsMetadata model.""" - - def test_load_from_dir_nonexistent(self, tmp_path: Path): - """Test loading metadata from nonexistent directory returns empty.""" - metadata = InstalledPluginsMetadata.load_from_dir(tmp_path / "nonexistent") - assert metadata.plugins == {} - - def test_load_from_dir_and_save_to_dir(self, tmp_path: Path): - """Test saving and loading metadata.""" - installed_dir = tmp_path / "installed" - installed_dir.mkdir() - - info = InstalledPluginInfo( - name="test-plugin", - version="1.0.0", - description="Test", - source="github:owner/test", - installed_at="2024-01-01T00:00:00Z", - install_path="/path/to/plugin", - ) - metadata = InstalledPluginsMetadata(plugins={"test-plugin": info}) - metadata.save_to_dir(installed_dir) - - loaded = InstalledPluginsMetadata.load_from_dir(installed_dir) - assert "test-plugin" in loaded.plugins - assert loaded.plugins["test-plugin"].name == "test-plugin" - assert loaded.plugins["test-plugin"].version == "1.0.0" - - def test_load_from_dir_invalid_json(self, tmp_path: Path): - """Test loading invalid JSON returns empty metadata.""" - installed_dir = tmp_path / "installed" - installed_dir.mkdir() - metadata_path = InstalledPluginsMetadata.get_path(installed_dir) - metadata_path.write_text("invalid json {") - - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - assert metadata.plugins == {} - - -# ============================================================================ -# Utility Function Tests +# Public API smoke tests # ============================================================================ def test_get_installed_plugins_dir_returns_default_path(): - """Test that default path is under ~/.openhands/plugins/installed/.""" path = get_installed_plugins_dir() assert ".openhands" in str(path) assert "plugins" in str(path) assert "installed" in str(path) -# ============================================================================ -# Install Plugin Tests -# ============================================================================ - - def test_install_from_local_path(sample_plugin_dir: Path, installed_dir: Path) -> None: - """Test installing a plugin from a local path.""" - info = install_plugin( - source=str(sample_plugin_dir), - installed_dir=installed_dir, - ) + info = install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) assert info.name == "sample-plugin" assert info.version == "1.0.0" assert info.source == str(sample_plugin_dir) - - # Verify plugin was copied - plugin_path = installed_dir / "sample-plugin" - assert plugin_path.exists() - assert (plugin_path / ".plugin" / "plugin.json").exists() - - # Verify metadata was updated - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - assert "sample-plugin" in metadata.plugins + assert (installed_dir / "sample-plugin" / ".plugin" / "plugin.json").exists() def test_install_already_exists_raises_error( sample_plugin_dir: Path, installed_dir: Path ) -> None: - """Test that installing an existing plugin raises FileExistsError.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - with pytest.raises(FileExistsError, match="already installed"): install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) @@ -203,182 +98,38 @@ def test_install_already_exists_raises_error( def test_install_with_force_overwrites( sample_plugin_dir: Path, installed_dir: Path ) -> None: - """Test that force=True overwrites existing installation.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - marker_file = installed_dir / "sample-plugin" / "marker.txt" - marker_file.write_text("original") + marker = installed_dir / "sample-plugin" / "marker.txt" + marker.write_text("original") install_plugin( source=str(sample_plugin_dir), installed_dir=installed_dir, force=True, ) - - assert not marker_file.exists() - - -@patch("openhands.sdk.plugin.installed.fetch_plugin_with_resolution") -def test_install_from_github_mocked( - mock_fetch, sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test installing a plugin from GitHub (mocked).""" - mock_fetch.return_value = (sample_plugin_dir, "abc123def456") - - info = install_plugin( - source="github:owner/sample-plugin", - ref="v1.0.0", - installed_dir=installed_dir, - ) - - mock_fetch.assert_called_once_with( - source="github:owner/sample-plugin", - ref="v1.0.0", - repo_path=None, - update=True, - ) - assert info.name == "sample-plugin" - assert info.source == "github:owner/sample-plugin" - assert info.resolved_ref == "abc123def456" - - -def test_install_invalid_plugin_name_raises_error( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test that installing a plugin with an invalid manifest name fails.""" - manifest_path = sample_plugin_dir / ".plugin" / "plugin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "bad_name" # not kebab-case - manifest_path.write_text(json.dumps(manifest)) - - with pytest.raises(ValueError, match="Invalid plugin name"): - install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - -# ============================================================================ -# Uninstall Plugin Tests -# ============================================================================ + assert not marker.exists() def test_uninstall_existing_plugin( sample_plugin_dir: Path, installed_dir: Path ) -> None: - """Test uninstalling an existing plugin.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - result = uninstall_plugin("sample-plugin", installed_dir=installed_dir) - - assert result is True + assert uninstall_plugin("sample-plugin", installed_dir=installed_dir) assert not (installed_dir / "sample-plugin").exists() - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - assert "sample-plugin" not in metadata.plugins - - -def test_uninstall_nonexistent_plugin(installed_dir: Path) -> None: - """Test uninstalling a plugin that doesn't exist.""" - result = uninstall_plugin("nonexistent", installed_dir=installed_dir) - assert result is False - - -def test_uninstall_untracked_plugin_does_not_delete( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test that uninstall refuses to delete untracked plugin directories.""" - dest = installed_dir / "untracked-plugin" - shutil.copytree(sample_plugin_dir, dest) - - manifest_path = dest / ".plugin" / "plugin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "untracked-plugin" - manifest_path.write_text(json.dumps(manifest)) - - result = uninstall_plugin("untracked-plugin", installed_dir=installed_dir) - - assert result is False - assert dest.exists() - - -def test_uninstall_invalid_name_raises_error(installed_dir: Path) -> None: - """Test that invalid plugin names are rejected.""" - with pytest.raises(ValueError, match="Invalid plugin name"): - uninstall_plugin("../evil", installed_dir=installed_dir) - - -# ============================================================================ -# List Installed Plugins Tests -# ============================================================================ - - -def test_list_empty_directory(installed_dir: Path) -> None: - """Test listing plugins from empty directory.""" - plugins = list_installed_plugins(installed_dir=installed_dir) - assert plugins == [] - def test_list_installed_plugins(sample_plugin_dir: Path, installed_dir: Path) -> None: - """Test listing installed plugins.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - plugins = list_installed_plugins(installed_dir=installed_dir) - assert len(plugins) == 1 assert plugins[0].name == "sample-plugin" - assert plugins[0].version == "1.0.0" - - -def test_list_discovers_untracked_plugins( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test that list discovers plugins not in metadata.""" - dest = installed_dir / "manual-plugin" - shutil.copytree(sample_plugin_dir, dest) - - manifest_path = dest / ".plugin" / "plugin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "manual-plugin" - manifest_path.write_text(json.dumps(manifest)) - - plugins = list_installed_plugins(installed_dir=installed_dir) - - assert len(plugins) == 1 - assert plugins[0].name == "manual-plugin" - assert plugins[0].source == "local" - - -def test_list_cleans_up_missing_plugins( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test that list removes metadata for missing plugins.""" - install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - shutil.rmtree(installed_dir / "sample-plugin") - - plugins = list_installed_plugins(installed_dir=installed_dir) - - assert len(plugins) == 0 - metadata = InstalledPluginsMetadata.load_from_dir(installed_dir) - assert "sample-plugin" not in metadata.plugins - - -# ============================================================================ -# Load Installed Plugins Tests -# ============================================================================ - - -def test_load_empty_directory(installed_dir: Path) -> None: - """Test loading plugins from empty directory.""" - plugins = load_installed_plugins(installed_dir=installed_dir) - assert plugins == [] def test_load_installed_plugins(sample_plugin_dir: Path, installed_dir: Path) -> None: - """Test loading installed plugins.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - plugins = load_installed_plugins(installed_dir=installed_dir) - assert len(plugins) == 1 + assert isinstance(plugins[0], Plugin) assert plugins[0].name == "sample-plugin" assert len(plugins[0].skills) == 1 @@ -387,11 +138,9 @@ def test_disable_plugin_filters_load( sample_plugin_dir: Path, installed_dir: Path ) -> None: install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) + assert disable_plugin("sample-plugin", installed_dir=installed_dir) - assert disable_plugin("sample-plugin", installed_dir=installed_dir) is True - - plugins = load_installed_plugins(installed_dir=installed_dir) - assert plugins == [] + assert load_installed_plugins(installed_dir=installed_dir) == [] info = get_installed_plugin("sample-plugin", installed_dir=installed_dir) assert info is not None assert info.enabled is False @@ -402,60 +151,30 @@ def test_enable_plugin_restores_load( ) -> None: install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) disable_plugin("sample-plugin", installed_dir=installed_dir) - - assert enable_plugin("sample-plugin", installed_dir=installed_dir) is True + assert enable_plugin("sample-plugin", installed_dir=installed_dir) plugins = load_installed_plugins(installed_dir=installed_dir) assert len(plugins) == 1 assert plugins[0].name == "sample-plugin" -# ============================================================================ -# Get Installed Plugin Tests -# ============================================================================ - - def test_get_existing_plugin(sample_plugin_dir: Path, installed_dir: Path) -> None: - """Test getting info for an existing plugin.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - info = get_installed_plugin("sample-plugin", installed_dir=installed_dir) - assert info is not None assert info.name == "sample-plugin" def test_get_nonexistent_plugin(installed_dir: Path) -> None: - """Test getting info for a nonexistent plugin.""" - info = get_installed_plugin("nonexistent", installed_dir=installed_dir) - assert info is None - - -def test_get_plugin_with_missing_directory( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test getting info when plugin directory is missing.""" - install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - shutil.rmtree(installed_dir / "sample-plugin") - - info = get_installed_plugin("sample-plugin", installed_dir=installed_dir) - assert info is None - - -# ============================================================================ -# Update Plugin Tests -# ============================================================================ + assert get_installed_plugin("nonexistent", installed_dir=installed_dir) is None def test_update_existing_plugin_local( sample_plugin_dir: Path, installed_dir: Path ) -> None: - """Test updating an installed plugin from local source.""" install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) disable_plugin("sample-plugin", installed_dir=installed_dir) - # Modify the source to new version (sample_plugin_dir / ".plugin" / "plugin.json").write_text( json.dumps( { @@ -467,40 +186,13 @@ def test_update_existing_plugin_local( ) updated = update_plugin("sample-plugin", installed_dir=installed_dir) - assert updated is not None assert updated.version == "1.0.1" assert updated.enabled is False -def test_update_existing_plugin_mocked( - sample_plugin_dir: Path, installed_dir: Path -) -> None: - """Test updating fetches with ref=None to get latest.""" - # Install first without mocking - install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir) - - # Now mock for the update call only - with patch( - "openhands.sdk.plugin.installed.fetch_plugin_with_resolution" - ) as mock_fetch: - mock_fetch.return_value = (sample_plugin_dir, "newcommit123") - - info = update_plugin("sample-plugin", installed_dir=installed_dir) - - assert info is not None - assert info.resolved_ref == "newcommit123" - - mock_fetch.assert_called_once() - call_kwargs = mock_fetch.call_args[1] - assert call_kwargs["source"] == str(sample_plugin_dir) - assert call_kwargs["ref"] is None # Get latest - - def test_update_nonexistent_plugin(installed_dir: Path) -> None: - """Test updating a plugin that doesn't exist.""" - info = update_plugin("nonexistent", installed_dir=installed_dir) - assert info is None + assert update_plugin("nonexistent", installed_dir=installed_dir) is None # ============================================================================ @@ -510,7 +202,6 @@ def test_update_nonexistent_plugin(installed_dir: Path) -> None: @pytest.mark.network def test_install_from_github_with_repo_path(installed_dir: Path) -> None: - """Test installing a plugin from GitHub using repo_path for monorepo.""" try: info = install_plugin( source="github:OpenHands/agent-sdk", @@ -537,7 +228,6 @@ def test_install_from_github_with_repo_path(installed_dir: Path) -> None: @pytest.mark.network def test_install_from_github_with_ref(installed_dir: Path) -> None: - """Test installing a plugin from GitHub with specific ref.""" try: info = install_plugin( source="github:OpenHands/agent-sdk", @@ -551,7 +241,7 @@ def test_install_from_github_with_ref(installed_dir: Path) -> None: assert info.name == "code-quality" assert info.resolved_ref is not None - assert len(info.resolved_ref) == 40 # SHA length + assert len(info.resolved_ref) == 40 except PluginFetchError: pytest.skip("GitHub not accessible (network issue)") @@ -559,11 +249,6 @@ def test_install_from_github_with_ref(installed_dir: Path) -> None: @pytest.mark.network def test_install_document_skills_plugin(installed_dir: Path) -> None: - """Test installing the document-skills plugin from anthropics/skills repository. - - This tests loading a proper Claude Code plugin which bundles multiple skills - (xlsx, docx, pptx, pdf) in the skills/ subdirectory. - """ try: source = "github:anthropics/skills" info = install_plugin( @@ -577,30 +262,21 @@ def test_install_document_skills_plugin(installed_dir: Path) -> None: assert info.name == expected_name assert info.source == source - # Verify the plugin directory has the expected structure - install_path = Path(info.install_path) + install_path = info.install_path skills_dir = install_path / "skills" assert skills_dir.is_dir() - # Check that the expected skill directories exist for skill_name in ["pptx", "xlsx", "docx", "pdf"]: - skill_dir = skills_dir / skill_name - assert skill_dir.is_dir(), f"Expected skill directory: {skill_name}" - skill_md = skill_dir / "SKILL.md" - assert skill_md.exists(), f"Expected SKILL.md in {skill_name}" + assert (skills_dir / skill_name).is_dir() + assert (skills_dir / skill_name / "SKILL.md").exists() - # Verify skills are loaded from the plugin plugins = load_installed_plugins(installed_dir=installed_dir) doc_plugin = next((p for p in plugins if p.name == expected_name), None) assert doc_plugin is not None skills = doc_plugin.get_all_skills() - # Should have at least the 4 document skills assert len(skills) >= 4 skill_names = {s.name for s in skills} - assert "pptx" in skill_names - assert "xlsx" in skill_names - assert "docx" in skill_names - assert "pdf" in skill_names + assert {"pptx", "xlsx", "docx", "pdf"} <= skill_names except PluginFetchError: pytest.skip("GitHub not accessible (network issue)") diff --git a/tests/sdk/skills/test_installed_skills.py b/tests/sdk/skills/test_installed_skills.py index 02b2b3086a..472c5b7e69 100644 --- a/tests/sdk/skills/test_installed_skills.py +++ b/tests/sdk/skills/test_installed_skills.py @@ -1,38 +1,41 @@ -"""Tests for installed skills management.""" +"""Tests for installed skills management. + +These tests verify the public API in ``openhands.sdk.skills.installed`` +delegates correctly to ``InstallationManager``. Internal metadata and +sync logic is already covered by ``tests/sdk/extensions/installation/``. +""" from __future__ import annotations -import shutil +import json from pathlib import Path import pytest from openhands.sdk.skills import ( - InstalledSkillsMetadata, + Skill, disable_skill, enable_skill, get_installed_skill, get_installed_skills_dir, install_skill, + install_skills_from_marketplace, list_installed_skills, load_installed_skills, uninstall_skill, update_skill, ) -from openhands.sdk.skills.exceptions import SkillValidationError def _create_skill_dir( base_dir: Path, dir_name: str, *, - frontmatter_name: str | None = None, description: str = "A test skill", ) -> Path: skill_dir = base_dir / dir_name skill_dir.mkdir(parents=True) - name = frontmatter_name or dir_name - skill_md = f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n" + skill_md = f"---\nname: {dir_name}\ndescription: {description}\n---\n# {dir_name}\n" (skill_dir / "SKILL.md").write_text(skill_md) return skill_dir @@ -49,6 +52,11 @@ def sample_skill_dir(tmp_path: Path) -> Path: return _create_skill_dir(tmp_path, "sample-skill") +# ============================================================================ +# Public API smoke tests +# ============================================================================ + + def test_get_installed_skills_dir_returns_default_path() -> None: path = get_installed_skills_dir() assert ".openhands" in str(path) @@ -62,20 +70,13 @@ def test_install_from_local_path(sample_skill_dir: Path, installed_dir: Path) -> assert info.name == "sample-skill" assert info.source == str(sample_skill_dir) assert info.description == "A test skill" - - skill_path = installed_dir / "sample-skill" - assert skill_path.exists() - assert (skill_path / "SKILL.md").exists() - - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - assert "sample-skill" in metadata.skills + assert (installed_dir / "sample-skill" / "SKILL.md").exists() def test_install_already_exists_raises_error( sample_skill_dir: Path, installed_dir: Path ) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - with pytest.raises(FileExistsError, match="already installed"): install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) @@ -84,85 +85,35 @@ def test_install_with_force_overwrites( sample_skill_dir: Path, installed_dir: Path ) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - - marker_file = installed_dir / "sample-skill" / "marker.txt" - marker_file.write_text("original") + marker = installed_dir / "sample-skill" / "marker.txt" + marker.write_text("original") install_skill( source=str(sample_skill_dir), installed_dir=installed_dir, force=True, ) - - assert not marker_file.exists() - - -def test_install_invalid_skill_name_raises_error( - tmp_path: Path, installed_dir: Path -) -> None: - invalid_skill_dir = _create_skill_dir( - tmp_path, - "bad-skill", - frontmatter_name="Bad_Name", - ) - - with pytest.raises(SkillValidationError): - install_skill(source=str(invalid_skill_dir), installed_dir=installed_dir) + assert not marker.exists() def test_uninstall_existing_skill(sample_skill_dir: Path, installed_dir: Path) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - - assert uninstall_skill("sample-skill", installed_dir=installed_dir) is True + assert uninstall_skill("sample-skill", installed_dir=installed_dir) assert not (installed_dir / "sample-skill").exists() -def test_list_empty_directory(installed_dir: Path) -> None: - assert list_installed_skills(installed_dir=installed_dir) == [] - - def test_list_installed_skills(sample_skill_dir: Path, installed_dir: Path) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - skills = list_installed_skills(installed_dir=installed_dir) - assert len(skills) == 1 assert skills[0].name == "sample-skill" -def test_list_discovers_untracked_skills(installed_dir: Path) -> None: - _create_skill_dir(installed_dir, "manual-skill") - - skills = list_installed_skills(installed_dir=installed_dir) - - assert len(skills) == 1 - assert skills[0].name == "manual-skill" - assert skills[0].source == "local" - - -def test_list_cleans_up_missing_skills( - sample_skill_dir: Path, installed_dir: Path -) -> None: - install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - shutil.rmtree(installed_dir / "sample-skill") - - skills = list_installed_skills(installed_dir=installed_dir) - - assert skills == [] - metadata = InstalledSkillsMetadata.load_from_dir(installed_dir) - assert "sample-skill" not in metadata.skills - - -def test_load_empty_directory(installed_dir: Path) -> None: - assert load_installed_skills(installed_dir=installed_dir) == [] - - def test_load_installed_skills(sample_skill_dir: Path, installed_dir: Path) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - skills = load_installed_skills(installed_dir=installed_dir) - assert len(skills) == 1 + assert isinstance(skills[0], Skill) assert skills[0].name == "sample-skill" @@ -170,11 +121,9 @@ def test_disable_skill_filters_load( sample_skill_dir: Path, installed_dir: Path ) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) + assert disable_skill("sample-skill", installed_dir=installed_dir) - assert disable_skill("sample-skill", installed_dir=installed_dir) is True - - skills = load_installed_skills(installed_dir=installed_dir) - assert skills == [] + assert load_installed_skills(installed_dir=installed_dir) == [] info = get_installed_skill("sample-skill", installed_dir=installed_dir) assert info is not None assert info.enabled is False @@ -185,61 +134,50 @@ def test_enable_skill_restores_load( ) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) disable_skill("sample-skill", installed_dir=installed_dir) - - assert enable_skill("sample-skill", installed_dir=installed_dir) is True + assert enable_skill("sample-skill", installed_dir=installed_dir) skills = load_installed_skills(installed_dir=installed_dir) assert len(skills) == 1 assert skills[0].name == "sample-skill" -def test_get_installed_skill_returns_info( - sample_skill_dir: Path, installed_dir: Path -) -> None: +def test_get_installed_skill(sample_skill_dir: Path, installed_dir: Path) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) - info = get_installed_skill("sample-skill", installed_dir=installed_dir) - assert info is not None assert info.name == "sample-skill" +def test_get_nonexistent_skill(installed_dir: Path) -> None: + assert get_installed_skill("nonexistent", installed_dir=installed_dir) is None + + def test_update_skill_reinstalls_from_source( sample_skill_dir: Path, installed_dir: Path ) -> None: install_skill(source=str(sample_skill_dir), installed_dir=installed_dir) disable_skill("sample-skill", installed_dir=installed_dir) - updated = ( - "---\n" - "name: sample-skill\n" - "description: Updated description\n" - "---\n" - "# sample-skill\n" + (sample_skill_dir / "SKILL.md").write_text( + "---\nname: sample-skill\ndescription: Updated description\n" + "---\n# sample-skill\n" ) - (sample_skill_dir / "SKILL.md").write_text(updated) info = update_skill("sample-skill", installed_dir=installed_dir) - assert info is not None assert info.description == "Updated description" assert info.enabled is False - installed_content = (installed_dir / "sample-skill" / "SKILL.md").read_text() - assert "Updated description" in installed_content + content = (installed_dir / "sample-skill" / "SKILL.md").read_text() + assert "Updated description" in content -def test_metadata_invalid_json_returns_empty(tmp_path: Path) -> None: - installed = tmp_path / "installed" - installed.mkdir() - metadata_path = installed / ".installed.json" - metadata_path.write_text("invalid json {") - - metadata = InstalledSkillsMetadata.load_from_dir(installed) - - assert metadata.skills == {} +def test_update_nonexistent_skill(installed_dir: Path) -> None: + assert update_skill("nonexistent", installed_dir=installed_dir) is None -# --- Tests for install_skills_from_marketplace --- +# ============================================================================ +# Marketplace tests +# ============================================================================ def _create_marketplace( @@ -247,15 +185,10 @@ def _create_marketplace( skills: list[dict[str, str]], plugins: list[dict[str, str]] | None = None, ) -> Path: - """Helper to create a marketplace directory with skills and optional plugins.""" marketplace_dir = base_dir / "marketplace" marketplace_dir.mkdir(parents=True) - plugin_dir = marketplace_dir / ".plugin" plugin_dir.mkdir() - - import json - manifest = { "name": "test-marketplace", "owner": {"name": "Test"}, @@ -263,74 +196,54 @@ def _create_marketplace( "plugins": plugins or [], } (plugin_dir / "marketplace.json").write_text(json.dumps(manifest)) - return marketplace_dir class TestInstallSkillsFromMarketplace: - """Tests for install_skills_from_marketplace function.""" - def test_install_local_skills(self, tmp_path: Path) -> None: - """Test installing local skills from marketplace.""" - from openhands.sdk.skills import install_skills_from_marketplace - - # Create marketplace with local skill marketplace_dir = _create_marketplace( tmp_path, skills=[{"name": "my-skill", "source": "./skills/my-skill"}], ) - - # Create the local skill skill_dir = marketplace_dir / "skills" / "my-skill" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: Test\n---\n# my-skill" ) - installed_dir = tmp_path / "installed" installed_dir.mkdir() installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir ) - assert len(installed) == 1 assert installed[0].name == "my-skill" - assert (installed_dir / "my-skill" / "SKILL.md").exists() def test_install_skills_force_overwrite(self, tmp_path: Path) -> None: - """Test force reinstalling existing skills.""" - from openhands.sdk.skills import install_skills_from_marketplace - marketplace_dir = _create_marketplace( tmp_path, skills=[{"name": "my-skill", "source": "./skills/my-skill"}], ) - skill_dir = marketplace_dir / "skills" / "my-skill" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: Original\n---\n# my-skill" ) - installed_dir = tmp_path / "installed" installed_dir.mkdir() - # First install install_skills_from_marketplace(marketplace_dir, installed_dir=installed_dir) - - # Update skill content (skill_dir / "SKILL.md").write_text( "---\nname: my-skill\ndescription: Updated\n---\n# my-skill" ) - # Reinstall without force - should not update + # Without force — already exists installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir, force=False ) - assert len(installed) == 0 # Already exists, not reinstalled + assert len(installed) == 0 - # Reinstall with force + # With force — overwrites installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir, force=True ) @@ -339,35 +252,24 @@ def test_install_skills_force_overwrite(self, tmp_path: Path) -> None: assert "Updated" in content def test_install_handles_missing_skill_source(self, tmp_path: Path) -> None: - """Test that missing skill sources are skipped gracefully.""" - from openhands.sdk.skills import install_skills_from_marketplace - marketplace_dir = _create_marketplace( tmp_path, skills=[{"name": "missing", "source": "./does-not-exist"}], ) - installed_dir = tmp_path / "installed" installed_dir.mkdir() - # Should not raise, just skip installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir ) - assert len(installed) == 0 def test_install_skills_from_plugin_directories(self, tmp_path: Path) -> None: - """Test that skills inside plugin directories are also installed.""" - from openhands.sdk.skills import install_skills_from_marketplace - marketplace_dir = _create_marketplace( tmp_path, - skills=[], # No standalone skills + skills=[], plugins=[{"name": "my-plugin", "source": "./plugins/my-plugin"}], ) - - # Create plugin with skills inside plugin_dir = marketplace_dir / "plugins" / "my-plugin" plugin_dir.mkdir(parents=True) (plugin_dir / "plugin.json").write_text('{"name": "my-plugin"}') @@ -377,35 +279,27 @@ def test_install_skills_from_plugin_directories(self, tmp_path: Path) -> None: (skill_dir / "SKILL.md").write_text( "---\nname: plugin-skill\ndescription: From plugin\n---\n# plugin-skill" ) - installed_dir = tmp_path / "installed" installed_dir.mkdir() installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir ) - assert len(installed) == 1 assert installed[0].name == "plugin-skill" def test_install_both_standalone_and_plugin_skills(self, tmp_path: Path) -> None: - """Test installing skills from both standalone entries and plugins.""" - from openhands.sdk.skills import install_skills_from_marketplace - marketplace_dir = _create_marketplace( tmp_path, skills=[{"name": "standalone", "source": "./skills/standalone"}], plugins=[{"name": "my-plugin", "source": "./plugins/my-plugin"}], ) - - # Create standalone skill standalone_dir = marketplace_dir / "skills" / "standalone" standalone_dir.mkdir(parents=True) (standalone_dir / "SKILL.md").write_text( "---\nname: standalone\ndescription: Standalone\n---\n# standalone" ) - # Create plugin with skill plugin_dir = marketplace_dir / "plugins" / "my-plugin" plugin_dir.mkdir(parents=True) (plugin_dir / "plugin.json").write_text('{"name": "my-plugin"}') @@ -415,13 +309,11 @@ def test_install_both_standalone_and_plugin_skills(self, tmp_path: Path) -> None (plugin_skill_dir / "SKILL.md").write_text( "---\nname: from-plugin\ndescription: From plugin\n---\n# from-plugin" ) - installed_dir = tmp_path / "installed" installed_dir.mkdir() installed = install_skills_from_marketplace( marketplace_dir, installed_dir=installed_dir ) - names = {s.name for s in installed} assert names == {"standalone", "from-plugin"} From 122ad223e224854e32d24e473f5ecec036932e77 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 13 Apr 2026 09:03:09 -0600 Subject: [PATCH 42/51] Update openhands-sdk/openhands/sdk/extensions/installation/metadata.py Co-authored-by: OpenHands Bot --- openhands-sdk/openhands/sdk/extensions/installation/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 3848a67101..6c6851b6ec 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -128,7 +128,7 @@ def load_from_dir(cls, installed_dir: Path) -> InstallationMetadata: try: with metadata_path.open() as f: - data = json.load(f) + return cls.model_validate(data) return cls.model_validate_json(data) except Exception as e: From 70930a968e151eb802144bc73e10e5231b92de4a Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 13 Apr 2026 09:10:21 -0600 Subject: [PATCH 43/51] fix json saving/loading in metadata --- .../openhands/sdk/extensions/installation/metadata.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 6c6851b6ec..2558c175bf 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from pathlib import Path from types import TracebackType from typing import ClassVar @@ -127,10 +126,7 @@ def load_from_dir(cls, installed_dir: Path) -> InstallationMetadata: return cls() try: - with metadata_path.open() as f: - return cls.model_validate(data) - return cls.model_validate_json(data) - + return cls.model_validate_json(metadata_path.read_text()) except Exception as e: logger.warning(f"Failed to load installed extension metadata: {e}") return cls() @@ -139,8 +135,7 @@ def save_to_dir(self, installed_dir: Path) -> None: """Save metadata to the installed extensions directory.""" metadata_path = self.get_metadata_path(installed_dir) metadata_path.parent.mkdir(parents=True, exist_ok=True) - with metadata_path.open("w") as f: - json.dump(self.model_dump_json(), f, indent=2) + metadata_path.write_text(self.model_dump_json(indent=2)) def validate_tracked(self, installed_dir: Path) -> list[InstallationInfo]: """Validate tracked extensions exist on disk. From 0ba72b5b322f81ee15fedcad1fe46f13d915c911 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Thu, 16 Apr 2026 12:03:23 -0600 Subject: [PATCH 44/51] fix: remove double JSON encoding in test helpers Use f.write(model_dump_json()) instead of json.dump(model_dump_json(), f) and model_validate_json(read_text()) instead of json.load + model_validate_json to avoid the confusing double-encode/decode dance. Co-authored-by: openhands --- .../extensions/installation/test_installation_manager.py | 9 ++++----- .../installation/test_installation_metadata.py | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/sdk/extensions/installation/test_installation_manager.py b/tests/sdk/extensions/installation/test_installation_manager.py index 219b00bfbd..02961bad07 100644 --- a/tests/sdk/extensions/installation/test_installation_manager.py +++ b/tests/sdk/extensions/installation/test_installation_manager.py @@ -1,4 +1,3 @@ -import json import shutil from pathlib import Path @@ -21,9 +20,9 @@ class MockExtension(BaseModel): class MockExtensionInstallationInterface(InstallationInterface): @staticmethod def load_from_dir(extension_dir: Path) -> MockExtension: - extension_path: Path = extension_dir / "extension.json" - with extension_path.open() as f: - return MockExtension.model_validate_json(json.load(f)) + return MockExtension.model_validate_json( + (extension_dir / "extension.json").read_text() + ) def _write_mock_extension( @@ -36,7 +35,7 @@ def _write_mock_extension( directory.mkdir(parents=True, exist_ok=True) ext = MockExtension(name=name, version=version, description=description) with (directory / "extension.json").open("w") as f: - json.dump(ext.model_dump_json(), f) + f.write(ext.model_dump_json()) return directory diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index ca95da2c5f..63a270fd65 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -1,4 +1,3 @@ -import json from pathlib import Path from pydantic import BaseModel @@ -19,9 +18,9 @@ class MockExtension(BaseModel): class MockExtensionInstallationInterface(InstallationInterface): @staticmethod def load_from_dir(extension_dir: Path) -> MockExtension: - extension_path: Path = extension_dir / "extension.json" - with extension_path.open() as f: - return MockExtension.model_validate_json(json.load(f)) + return MockExtension.model_validate_json( + (extension_dir / "extension.json").read_text() + ) def _write_mock_extension( @@ -34,7 +33,7 @@ def _write_mock_extension( directory.mkdir(parents=True, exist_ok=True) ext = MockExtension(name=name, version=version, description=description) with (directory / "extension.json").open("w") as f: - json.dump(ext.model_dump_json(), f) + f.write(ext.model_dump_json()) return directory From a307e5397a779b3248c9591a2bf427c9473129a6 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Tue, 21 Apr 2026 13:23:32 -0600 Subject: [PATCH 45/51] refactor: replace getattr guards with typed ExtensionProtocol Add version and description properties to ExtensionProtocol so from_extension() uses direct typed attribute access instead of getattr. Add version field to Skill so it satisfies the expanded protocol. Co-authored-by: openhands --- .../sdk/extensions/installation/info.py | 8 ++------ .../sdk/extensions/installation/interface.py | 18 ++++++++++-------- openhands-sdk/openhands/sdk/skills/skill.py | 4 ++++ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index 2bd6da5780..4f931af661 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -46,10 +46,6 @@ def from_extension( ) -> InstallationInfo: """Create an InstallationInfo from an extension and its install context. - Only ``extension.name`` is required by ``ExtensionProtocol``. - ``version`` and ``description`` are read with ``getattr`` so - extension types that omit them (e.g. skills) get sensible defaults. - Args: extension: Any object satisfying ``ExtensionProtocol``. source: Original source string (e.g. ``"github:owner/repo"``). @@ -59,8 +55,8 @@ def from_extension( """ return InstallationInfo( name=extension.name, - version=getattr(extension, "version", "1.0.0"), - description=getattr(extension, "description", None) or "", + version=extension.version, + description=extension.description or "", source=source, resolved_ref=resolved_ref, repo_path=repo_path, diff --git a/openhands-sdk/openhands/sdk/extensions/installation/interface.py b/openhands-sdk/openhands/sdk/extensions/installation/interface.py index 45e63e11e0..79e17a57f8 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/interface.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/interface.py @@ -4,20 +4,22 @@ class ExtensionProtocol(Protocol): - """Minimal structural protocol for installable extensions. + """Structural protocol for installable extensions. - Only ``name`` is required. ``version`` and ``description`` are read - via ``getattr`` in ``InstallationInfo.from_extension`` so that - extension types that don't carry those fields (e.g. skills) still - work without adapter wrappers. - - ``name`` is declared as a read-only property so that both plain - attributes and ``@property`` accessors satisfy the protocol. + All three properties are declared as read-only so that both plain + Pydantic field attributes and ``@property`` accessors satisfy the + protocol. """ @property def name(self) -> str: ... + @property + def version(self) -> str: ... + + @property + def description(self) -> str | None: ... + class InstallationInterface[T: ExtensionProtocol](ABC): """Abstract interface that teaches ``InstallationManager`` how to load ``T``. diff --git a/openhands-sdk/openhands/sdk/skills/skill.py b/openhands-sdk/openhands/sdk/skills/skill.py index 7bb140263b..b08f5a3b21 100644 --- a/openhands-sdk/openhands/sdk/skills/skill.py +++ b/openhands-sdk/openhands/sdk/skills/skill.py @@ -167,6 +167,10 @@ class Skill(BaseModel): MAX_DESCRIPTION_LENGTH: ClassVar[int] = 1024 # AgentSkills standard fields (https://agentskills.io/specification) + version: str = Field( + default="1.0.0", + description="Skill version (AgentSkills standard field).", + ) description: str | None = Field( default=None, description=( From 8c58ce8194af131c1cf04610a54d13cdb06e2aea Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Tue, 21 Apr 2026 13:24:43 -0600 Subject: [PATCH 46/51] fix: migrate legacy plugins/skills metadata keys to extensions Add a model_validator that transparently maps the old 'plugins' or 'skills' key to 'extensions' when loading existing .installed.json files, preserving enabled/disabled state for existing installations. Co-authored-by: openhands --- .../sdk/extensions/installation/metadata.py | 16 ++++- .../test_installation_metadata.py | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 2558c175bf..814eaf710e 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -2,9 +2,9 @@ from pathlib import Path from types import TracebackType -from typing import ClassVar +from typing import Any, ClassVar -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from openhands.sdk.extensions.installation.info import InstallationInfo from openhands.sdk.extensions.installation.interface import ( @@ -94,6 +94,18 @@ class InstallationMetadata(BaseModel): ) metadata_filename: ClassVar[str] = ".installed.json" + _LEGACY_KEYS: ClassVar[tuple[str, ...]] = ("plugins", "skills") + + @model_validator(mode="before") + @classmethod + def _migrate_legacy_keys(cls, data: Any) -> Any: + """Migrate old ``plugins`` / ``skills`` keys to ``extensions``.""" + if isinstance(data, dict) and "extensions" not in data: + for legacy_key in cls._LEGACY_KEYS: + if legacy_key in data: + data["extensions"] = data.pop(legacy_key) + break + return data @classmethod def open( diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index 63a270fd65..04d066fdce 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -37,6 +37,67 @@ def _write_mock_extension( return directory +# ============================================================================ +# Legacy Key Migration Tests +# ============================================================================ + + +def test_migrate_legacy_plugins_key(): + """Test that old {"plugins": {...}} format is migrated to extensions.""" + data = { + "plugins": { + "my-plugin": { + "name": "my-plugin", + "source": "github:owner/repo", + "install_path": "/tmp/installed/my-plugin", + } + } + } + metadata = InstallationMetadata.model_validate(data) + assert "my-plugin" in metadata.extensions + assert metadata.extensions["my-plugin"].name == "my-plugin" + + +def test_migrate_legacy_skills_key(): + """Test that old {"skills": {...}} format is migrated to extensions.""" + data = { + "skills": { + "my-skill": { + "name": "my-skill", + "source": "local", + "install_path": "/tmp/installed/my-skill", + "enabled": False, + } + } + } + metadata = InstallationMetadata.model_validate(data) + assert "my-skill" in metadata.extensions + assert metadata.extensions["my-skill"].enabled is False + + +def test_migrate_does_not_overwrite_extensions_key(): + """Test that migration is skipped when 'extensions' key already exists.""" + data = { + "extensions": { + "new-ext": { + "name": "new-ext", + "source": "local", + "install_path": "/tmp/installed/new-ext", + } + }, + "plugins": { + "old-plugin": { + "name": "old-plugin", + "source": "local", + "install_path": "/tmp/installed/old-plugin", + } + }, + } + metadata = InstallationMetadata.model_validate(data) + assert "new-ext" in metadata.extensions + assert "old-plugin" not in metadata.extensions + + # ============================================================================ # Load / Save Tests # ============================================================================ From a5bedcd62acc8c3441a80d4f8f9fc5200846864c Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Wed, 22 Apr 2026 09:35:29 -0600 Subject: [PATCH 47/51] Fix legacy key migration to merge all sources into extensions Previously, legacy keys (plugins/skills) were only migrated when the extensions key was absent, and even then only the first legacy key was used. Now all legacy keys are always merged into extensions, with explicit extensions entries winning on key conflicts. A warning is logged for each legacy key encountered. Co-authored-by: openhands --- .../sdk/extensions/installation/metadata.py | 24 ++++-- .../test_installation_metadata.py | 79 ++++++++++++++++++- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py index 814eaf710e..a0babd3187 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/metadata.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/metadata.py @@ -99,12 +99,24 @@ class InstallationMetadata(BaseModel): @model_validator(mode="before") @classmethod def _migrate_legacy_keys(cls, data: Any) -> Any: - """Migrate old ``plugins`` / ``skills`` keys to ``extensions``.""" - if isinstance(data, dict) and "extensions" not in data: - for legacy_key in cls._LEGACY_KEYS: - if legacy_key in data: - data["extensions"] = data.pop(legacy_key) - break + """Migrate old ``plugins`` / ``skills`` keys into ``extensions``. + + Legacy entries are merged into the existing ``extensions`` dict + (if any). Explicit ``extensions`` entries win on key conflicts. + """ + if not isinstance(data, dict): + return data + merged: dict[str, Any] = {} + for legacy_key in cls._LEGACY_KEYS: + if legacy_key in data: + logger.warning( + "Migrating legacy %r key to 'extensions'", + legacy_key, + ) + merged.update(data.pop(legacy_key)) + if merged: + merged.update(data.get("extensions") or {}) + data["extensions"] = merged return data @classmethod diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index 04d066fdce..5c1b6f0a37 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -1,5 +1,7 @@ +import logging from pathlib import Path +import pytest from pydantic import BaseModel from openhands.sdk.extensions.installation import ( @@ -75,8 +77,57 @@ def test_migrate_legacy_skills_key(): assert metadata.extensions["my-skill"].enabled is False -def test_migrate_does_not_overwrite_extensions_key(): - """Test that migration is skipped when 'extensions' key already exists.""" +def test_migrate_merges_both_legacy_keys(): + """Test that both plugins and skills are merged when both are present.""" + data = { + "plugins": { + "my-plugin": { + "name": "my-plugin", + "source": "github:owner/repo", + "install_path": "/tmp/installed/my-plugin", + } + }, + "skills": { + "my-skill": { + "name": "my-skill", + "source": "local", + "install_path": "/tmp/installed/my-skill", + } + }, + } + metadata = InstallationMetadata.model_validate(data) + assert "my-plugin" in metadata.extensions + assert "my-skill" in metadata.extensions + + +def test_migrate_legacy_key_logs_warning(caplog: pytest.LogCaptureFixture): + """Each legacy key that is migrated emits a warning.""" + data = { + "plugins": { + "p": { + "name": "p", + "source": "local", + "install_path": "/tmp/p", + } + }, + "skills": { + "s": { + "name": "s", + "source": "local", + "install_path": "/tmp/s", + } + }, + } + with caplog.at_level(logging.WARNING): + InstallationMetadata.model_validate(data) + + warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING] + assert any("plugins" in w for w in warnings) + assert any("skills" in w for w in warnings) + + +def test_migrate_merges_legacy_into_extensions(): + """Legacy keys are merged into extensions; extensions wins on conflicts.""" data = { "extensions": { "new-ext": { @@ -95,7 +146,29 @@ def test_migrate_does_not_overwrite_extensions_key(): } metadata = InstallationMetadata.model_validate(data) assert "new-ext" in metadata.extensions - assert "old-plugin" not in metadata.extensions + assert "old-plugin" in metadata.extensions + + +def test_migrate_extensions_wins_on_conflict(): + """When a name appears in both extensions and a legacy key, extensions wins.""" + data = { + "extensions": { + "shared": { + "name": "shared", + "source": "local", + "install_path": "/tmp/installed/shared", + } + }, + "plugins": { + "shared": { + "name": "shared", + "source": "github:owner/repo", + "install_path": "/tmp/installed/shared", + } + }, + } + metadata = InstallationMetadata.model_validate(data) + assert metadata.extensions["shared"].source == "local" # ============================================================================ From cbc2f8c44eeae7ea49f2fd113b2227bd9a452fba Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Wed, 22 Apr 2026 09:47:34 -0600 Subject: [PATCH 48/51] Use shared cache dir for extension fetches Move the git clone cache back to a shared location (~/.openhands/cache/extensions/) instead of per-installation-dir .cache/ directories. This restores cache sharing across extension types and keeps the cache in a well-known location. Co-authored-by: openhands --- .../openhands/sdk/extensions/installation/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 7577c08df2..082f99acb6 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -20,6 +20,8 @@ logger = get_logger(__name__) +DEFAULT_CACHE_DIR = Path.home() / ".openhands" / "cache" / "extensions" + @dataclass class InstallationManager[T: ExtensionProtocol]: @@ -81,7 +83,7 @@ def install( logger.info(f"Fetching extension from {source}") fetched_path, resolved_ref = fetch_with_resolution( source=source, - cache_dir=self.installation_dir / ".cache", + cache_dir=DEFAULT_CACHE_DIR, ref=ref, repo_path=repo_path, update=True, From dd02ce06160bf47c37a43511a6ac270634ba4d4a Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Wed, 22 Apr 2026 09:50:37 -0600 Subject: [PATCH 49/51] default version updates --- openhands-sdk/openhands/sdk/extensions/installation/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/info.py b/openhands-sdk/openhands/sdk/extensions/installation/info.py index 4f931af661..d1ef16ae60 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/info.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/info.py @@ -16,7 +16,7 @@ class InstallationInfo(BaseModel): """ name: str = Field(description="Extension name") - version: str = Field(default="1.0.0", description="Extension version") + version: str = Field(default="", description="Extension version") description: str = Field(default="", description="Extension description") enabled: bool = Field(default=True, description="Whether the extension is enabled") From 601baa2b5cd447cfe7d1315daaea7c44c3cfae1a Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 27 Apr 2026 09:51:12 -0600 Subject: [PATCH 50/51] fix(examples): update metadata key from "plugins" to "extensions" The installation metadata format was unified under "extensions" but the loading_plugins example still referenced the old "plugins" key. Co-authored-by: openhands --- examples/05_skills_and_plugins/02_loading_plugins/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/05_skills_and_plugins/02_loading_plugins/main.py b/examples/05_skills_and_plugins/02_loading_plugins/main.py index 2aa94127ff..6e70132b65 100644 --- a/examples/05_skills_and_plugins/02_loading_plugins/main.py +++ b/examples/05_skills_and_plugins/02_loading_plugins/main.py @@ -199,13 +199,13 @@ def demo_enable_disable_plugin(installed_dir: Path, plugin_name: str) -> None: ] metadata = json.loads((installed_dir / ".installed.json").read_text()) - assert metadata["plugins"][plugin_name]["enabled"] is False + assert metadata["extensions"][plugin_name]["enabled"] is False assert enable_plugin(plugin_name, installed_dir=installed_dir) is True print_state("After re-enable", installed_dir) metadata = json.loads((installed_dir / ".installed.json").read_text()) - assert metadata["plugins"][plugin_name]["enabled"] is True + assert metadata["extensions"][plugin_name]["enabled"] is True assert plugin_name in [ plugin.name for plugin in load_installed_plugins(installed_dir=installed_dir) ] From f4f7368dbba7d52e116831d897035be70625ed40 Mon Sep 17 00:00:00 2001 From: Calvin Smith Date: Mon, 27 Apr 2026 12:20:47 -0600 Subject: [PATCH 51/51] fix: address review comments on installation module - Resolve installation_dir to absolute path in InstallationManager.__post_init__ to prevent breakage if working directory changes - Add parametrized path traversal tests for validate_extension_name - Add test for legacy key conflict when both plugins and skills share a name Co-authored-by: openhands --- .../sdk/extensions/installation/manager.py | 3 +++ .../test_installation_metadata.py | 23 +++++++++++++++++++ .../installation/test_installation_utils.py | 16 +++++++++++++ 3 files changed, 42 insertions(+) diff --git a/openhands-sdk/openhands/sdk/extensions/installation/manager.py b/openhands-sdk/openhands/sdk/extensions/installation/manager.py index 082f99acb6..25eda7f17d 100644 --- a/openhands-sdk/openhands/sdk/extensions/installation/manager.py +++ b/openhands-sdk/openhands/sdk/extensions/installation/manager.py @@ -40,6 +40,9 @@ class InstallationManager[T: ExtensionProtocol]: installation_dir: Path installation_interface: InstallationInterface[T] + def __post_init__(self) -> None: + self.installation_dir = self.installation_dir.resolve() + @property def metadata_session(self) -> MetadataSession: """Open a metadata session bound to this manager's dir and interface.""" diff --git a/tests/sdk/extensions/installation/test_installation_metadata.py b/tests/sdk/extensions/installation/test_installation_metadata.py index 5c1b6f0a37..bf9d662192 100644 --- a/tests/sdk/extensions/installation/test_installation_metadata.py +++ b/tests/sdk/extensions/installation/test_installation_metadata.py @@ -171,6 +171,29 @@ def test_migrate_extensions_wins_on_conflict(): assert metadata.extensions["shared"].source == "local" +def test_migrate_conflicting_legacy_keys(): + """When both plugins and skills have the same name, the later key wins.""" + data = { + "plugins": { + "shared": { + "name": "shared", + "source": "github:A", + "install_path": "/tmp/installed/shared", + } + }, + "skills": { + "shared": { + "name": "shared", + "source": "github:B", + "install_path": "/tmp/installed/shared", + } + }, + } + metadata = InstallationMetadata.model_validate(data) + # skills is iterated after plugins in _LEGACY_KEYS, so it overwrites + assert metadata.extensions["shared"].source == "github:B" + + # ============================================================================ # Load / Save Tests # ============================================================================ diff --git a/tests/sdk/extensions/installation/test_installation_utils.py b/tests/sdk/extensions/installation/test_installation_utils.py index 711d2fffe1..ee85d2c775 100644 --- a/tests/sdk/extensions/installation/test_installation_utils.py +++ b/tests/sdk/extensions/installation/test_installation_utils.py @@ -20,3 +20,19 @@ def test_validate_extension_name(input: str, valid: bool): else: with pytest.raises(ValueError): validate_extension_name(input) + + +@pytest.mark.parametrize( + "invalid", + [ + "../evil", + "../../bad", + "/absolute", + "./relative", + "test/", + ".hidden", + ], +) +def test_validate_rejects_path_traversal(invalid: str): + with pytest.raises(ValueError, match="Invalid extension name"): + validate_extension_name(invalid)