Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a71198a
extensions module, entry point stubs
csmith49 Apr 10, 2026
4858e59
full fetch logic
csmith49 Apr 10, 2026
dae2ddc
Port fetch tests to tests/sdk/extensions/test_fetch.py
csmith49 Apr 10, 2026
5187d87
minor
csmith49 Apr 10, 2026
d1b3b6b
Add SourceType docstring and fix stale plugin refs in extensions/fetc…
csmith49 Apr 10, 2026
e099061
Refactor plugin and skills fetch to delegate to extensions.fetch
csmith49 Apr 10, 2026
b3e7296
Remove parse_plugin_source/get_cache_path, slim plugin tests
csmith49 Apr 10, 2026
6679478
Merge branch 'main' into feat/extensions-utils
csmith49 Apr 10, 2026
6460535
cleaning up error messages
csmith49 Apr 10, 2026
e1413f5
Fix PluginFetchError to preserve original error message
csmith49 Apr 10, 2026
717c854
initial installation manager api
csmith49 Apr 11, 2026
ee9fe39
type strengthening
csmith49 Apr 11, 2026
a469127
relaxing type def
csmith49 Apr 11, 2026
9399e24
installation manager docs first pass, default metadata added
csmith49 Apr 11, 2026
a1973a7
utils file, simplifying interface to install manger
csmith49 Apr 11, 2026
6fd1e1e
update/get
csmith49 Apr 11, 2026
0a3e154
metadata validation
csmith49 Apr 11, 2026
005b30a
re-org into module
csmith49 Apr 11, 2026
c3911bc
removing unused utils
csmith49 Apr 11, 2026
f83540a
genericized metadata
csmith49 Apr 11, 2026
c48c7cf
interface instead of badly linked generics
csmith49 Apr 11, 2026
cb30b92
refining generic in manager
csmith49 Apr 11, 2026
c9ff175
list/load
csmith49 Apr 11, 2026
509dd24
enable/disable
csmith49 Apr 11, 2026
7786088
install/uninstall
csmith49 Apr 11, 2026
1d52b89
minor todos
csmith49 Apr 11, 2026
3db00dc
readme and init file, initial
csmith49 Apr 11, 2026
add5d69
initial utils tests
csmith49 Apr 11, 2026
a813f19
rename installation info
csmith49 Apr 11, 2026
f398ea9
rename extension protocol and installation interface
csmith49 Apr 11, 2026
8ef6746
better installation info construction
csmith49 Apr 11, 2026
594e13c
minor import fixes
csmith49 Apr 11, 2026
53498f4
installation info tests
csmith49 Apr 11, 2026
1032c35
metadata rename and tests
csmith49 Apr 11, 2026
eb7f348
installation manager rename -> install tests
csmith49 Apr 11, 2026
3836806
tests for install
csmith49 Apr 11, 2026
5152d82
more tests for manager
csmith49 Apr 11, 2026
cac6693
improved test coverage
csmith49 Apr 11, 2026
078fb2a
docs pass
csmith49 Apr 11, 2026
74d04f2
metadata context manager
csmith49 Apr 11, 2026
cb95e27
minor documentation
csmith49 Apr 11, 2026
0be0aee
repalce interface for skills/plugins
csmith49 Apr 13, 2026
122ad22
Update openhands-sdk/openhands/sdk/extensions/installation/metadata.py
csmith49 Apr 13, 2026
70930a9
fix json saving/loading in metadata
csmith49 Apr 13, 2026
d31f72a
Merge branch 'main' into feat/installed-extensions
csmith49 Apr 13, 2026
828ef3e
Merge branch 'main' into feat/installed-extensions
csmith49 Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions openhands-sdk/openhands/sdk/extensions/installation/README.md
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 openhands-sdk/openhands/sdk/extensions/installation/__init__.py
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",
]
68 changes: 68 additions & 0 deletions openhands-sdk/openhands/sdk/extensions/installation/info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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="1.0.0", 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",
)
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:
"""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"``).
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,
version=getattr(extension, "version", "1.0.0"),
description=getattr(extension, "description", None) or "",
source=source,
resolved_ref=resolved_ref,
repo_path=repo_path,
install_path=install_path,
)
31 changes: 31 additions & 0 deletions openhands-sdk/openhands/sdk/extensions/installation/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Protocol


class ExtensionProtocol(Protocol):
"""Minimal 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.
"""

@property
def name(self) -> str: ...


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: ...
Loading
Loading