-
Notifications
You must be signed in to change notification settings - Fork 241
feat: Extension installation module #2811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
66 commits
Select commit
Hold shift + click to select a range
a71198a
extensions module, entry point stubs
csmith49 4858e59
full fetch logic
csmith49 dae2ddc
Port fetch tests to tests/sdk/extensions/test_fetch.py
csmith49 5187d87
minor
csmith49 d1b3b6b
Add SourceType docstring and fix stale plugin refs in extensions/fetc…
csmith49 e099061
Refactor plugin and skills fetch to delegate to extensions.fetch
csmith49 b3e7296
Remove parse_plugin_source/get_cache_path, slim plugin tests
csmith49 6679478
Merge branch 'main' into feat/extensions-utils
csmith49 6460535
cleaning up error messages
csmith49 e1413f5
Fix PluginFetchError to preserve original error message
csmith49 717c854
initial installation manager api
csmith49 ee9fe39
type strengthening
csmith49 a469127
relaxing type def
csmith49 9399e24
installation manager docs first pass, default metadata added
csmith49 a1973a7
utils file, simplifying interface to install manger
csmith49 6fd1e1e
update/get
csmith49 0a3e154
metadata validation
csmith49 005b30a
re-org into module
csmith49 c3911bc
removing unused utils
csmith49 f83540a
genericized metadata
csmith49 c48c7cf
interface instead of badly linked generics
csmith49 cb30b92
refining generic in manager
csmith49 c9ff175
list/load
csmith49 509dd24
enable/disable
csmith49 7786088
install/uninstall
csmith49 1d52b89
minor todos
csmith49 3db00dc
readme and init file, initial
csmith49 add5d69
initial utils tests
csmith49 a813f19
rename installation info
csmith49 f398ea9
rename extension protocol and installation interface
csmith49 8ef6746
better installation info construction
csmith49 594e13c
minor import fixes
csmith49 53498f4
installation info tests
csmith49 1032c35
metadata rename and tests
csmith49 eb7f348
installation manager rename -> install tests
csmith49 3836806
tests for install
csmith49 5152d82
more tests for manager
csmith49 cac6693
improved test coverage
csmith49 078fb2a
docs pass
csmith49 74d04f2
metadata context manager
csmith49 cb95e27
minor documentation
csmith49 0be0aee
repalce interface for skills/plugins
csmith49 122ad22
Update openhands-sdk/openhands/sdk/extensions/installation/metadata.py
csmith49 70930a9
fix json saving/loading in metadata
csmith49 d31f72a
Merge branch 'main' into feat/installed-extensions
csmith49 828ef3e
Merge branch 'main' into feat/installed-extensions
csmith49 fad48b2
Merge branch 'main' into feat/installed-extensions
csmith49 0ba72b5
fix: remove double JSON encoding in test helpers
csmith49 a307e53
refactor: replace getattr guards with typed ExtensionProtocol
csmith49 8c58ce8
fix: migrate legacy plugins/skills metadata keys to extensions
csmith49 7aa62a3
Merge branch 'main' into feat/installed-extensions
csmith49 5317134
Merge branch 'main' into feat/installed-extensions
csmith49 a5bedcd
Fix legacy key migration to merge all sources into extensions
csmith49 cbc2f8c
Use shared cache dir for extension fetches
csmith49 dd02ce0
default version updates
csmith49 0a719bb
Merge branch 'main' into feat/installed-extensions
csmith49 7f022cf
Merge branch 'main' into feat/installed-extensions
csmith49 9ac2419
Merge branch 'main' into feat/installed-extensions
csmith49 c9b3dd1
Merge branch 'main' into feat/installed-extensions
csmith49 319e57d
Merge branch 'main' into feat/installed-extensions
csmith49 ae9e801
Merge branch 'main' into feat/installed-extensions
xingyaoww b4b58c1
Merge branch 'main' into feat/installed-extensions
csmith49 601baa2
fix(examples): update metadata key from "plugins" to "extensions"
csmith49 adcea6e
Merge branch 'main' into feat/installed-extensions
csmith49 f4f7368
fix: address review comments on installation module
csmith49 907c4b8
Merge branch 'main' into feat/installed-extensions
csmith49 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
93 changes: 93 additions & 0 deletions
93
openhands-sdk/openhands/sdk/extensions/installation/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| # Installation | ||
|
|
||
| Generic framework for installing, tracking, and loading extensions from local | ||
| or remote sources. | ||
|
|
||
| ## Overview | ||
|
|
||
| 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. | ||
|
|
||
| ## Usage | ||
|
|
||
| ### 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`). | ||
20 changes: 20 additions & 0 deletions
20
openhands-sdk/openhands/sdk/extensions/installation/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| from openhands.sdk.extensions.installation.info import InstallationInfo | ||
| from openhands.sdk.extensions.installation.interface import ( | ||
| ExtensionProtocol, | ||
| InstallationInterface, | ||
| ) | ||
| from openhands.sdk.extensions.installation.manager import InstallationManager | ||
| from openhands.sdk.extensions.installation.metadata import ( | ||
| InstallationMetadata, | ||
| MetadataSession, | ||
| ) | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "InstallationInfo", | ||
| "InstallationInterface", | ||
| "ExtensionProtocol", | ||
| "InstallationManager", | ||
| "InstallationMetadata", | ||
| "MetadataSession", | ||
| ] |
64 changes: 64 additions & 0 deletions
64
openhands-sdk/openhands/sdk/extensions/installation/info.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| 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): | ||
| """Metadata record for a single installed extension. | ||
|
|
||
| Stored (keyed by name) inside ``InstallationMetadata`` and persisted to | ||
| the ``.installed.json`` file in the installation directory. | ||
| """ | ||
|
|
||
| name: str = Field(description="Extension name") | ||
| 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") | ||
|
|
||
| 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( | ||
| default_factory=lambda: datetime.now(UTC).isoformat(), | ||
| description="ISO 8601 timestamp of installation", | ||
| ) | ||
|
VascoSch92 marked this conversation as resolved.
csmith49 marked this conversation as resolved.
|
||
| install_path: Path = Field(description="Path where the extension is installed") | ||
|
csmith49 marked this conversation as resolved.
|
||
|
|
||
| @staticmethod | ||
| def from_extension( | ||
| extension: ExtensionProtocol, | ||
| source: str, | ||
| install_path: Path, | ||
| resolved_ref: str | None = None, | ||
| repo_path: str | None = None, | ||
| ) -> InstallationInfo: | ||
| """Create an InstallationInfo from an extension and its install context. | ||
|
|
||
| Args: | ||
| 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( | ||
|
csmith49 marked this conversation as resolved.
|
||
| name=extension.name, | ||
| version=extension.version, | ||
| description=extension.description or "", | ||
| source=source, | ||
| resolved_ref=resolved_ref, | ||
| repo_path=repo_path, | ||
| install_path=install_path, | ||
| ) | ||
33 changes: 33 additions & 0 deletions
33
openhands-sdk/openhands/sdk/extensions/installation/interface.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| from abc import ABC, abstractmethod | ||
| from pathlib import Path | ||
| from typing import Protocol | ||
|
|
||
|
|
||
| class ExtensionProtocol(Protocol): | ||
| """Structural protocol for installable extensions. | ||
|
|
||
| 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``. | ||
|
|
||
| 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: ... |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.