openhands-agent-server 1.29.2__tar.gz → 1.29.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/api.py +2 -0
  3. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_service.py +14 -3
  4. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/event_service.py +14 -12
  5. openhands_agent_server-1.29.3/openhands/agent_server/plugins_router.py +333 -0
  6. openhands_agent_server-1.29.3/openhands/agent_server/plugins_service.py +295 -0
  7. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  8. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
  9. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/pyproject.toml +1 -1
  10. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__init__.py +0 -0
  11. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__main__.py +0 -0
  12. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/_secrets_exposure.py +0 -0
  13. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/agent_profiles_router.py +0 -0
  14. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/auth_router.py +0 -0
  15. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/bash_router.py +0 -0
  16. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/bash_service.py +0 -0
  17. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/config.py +0 -0
  18. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_lease.py +0 -0
  19. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_router.py +0 -0
  20. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/dependencies.py +0 -0
  21. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/desktop_router.py +0 -0
  22. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/desktop_service.py +0 -0
  23. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/Dockerfile +0 -0
  24. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/build.py +0 -0
  25. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/wallpaper.svg +0 -0
  26. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/env_parser.py +0 -0
  27. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/event_router.py +0 -0
  28. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/file_router.py +0 -0
  29. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/git_router.py +0 -0
  30. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/hooks_router.py +0 -0
  31. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/hooks_service.py +0 -0
  32. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/init_router.py +0 -0
  33. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/llm_router.py +0 -0
  34. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/logging_config.py +0 -0
  35. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/mcp_router.py +0 -0
  36. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/middleware.py +0 -0
  37. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/models.py +0 -0
  38. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/__init__.py +0 -0
  39. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/models.py +0 -0
  40. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/router.py +0 -0
  41. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/service.py +0 -0
  42. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openapi.py +0 -0
  43. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/__init__.py +0 -0
  44. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/models.py +0 -0
  45. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/store.py +0 -0
  46. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/profiles_router.py +0 -0
  47. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/pub_sub.py +0 -0
  48. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/py.typed +0 -0
  49. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/server_details_router.py +0 -0
  50. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/settings_router.py +0 -0
  51. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/skills_router.py +0 -0
  52. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/skills_service.py +0 -0
  53. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/sockets.py +0 -0
  54. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/tool_preload_service.py +0 -0
  55. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/tool_router.py +0 -0
  56. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/utils.py +0 -0
  57. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  58. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  59. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_router.py +0 -0
  60. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_service.py +0 -0
  61. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/workspace_router.py +0 -0
  62. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/workspaces_router.py +0 -0
  63. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  64. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  65. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/requires.txt +0 -0
  66. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/top_level.txt +0 -0
  67. {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.29.2
3
+ Version: 1.29.3
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -50,6 +50,7 @@ from openhands.agent_server.openai.router import (
50
50
  check_openai_api_key,
51
51
  openai_router,
52
52
  )
53
+ from openhands.agent_server.plugins_router import plugins_router
53
54
  from openhands.agent_server.profiles_router import profiles_router
54
55
  from openhands.agent_server.server_details_router import (
55
56
  get_server_info,
@@ -348,6 +349,7 @@ def _add_api_routes(app: FastAPI) -> None:
348
349
  api_router.include_router(vscode_router)
349
350
  api_router.include_router(desktop_router)
350
351
  api_router.include_router(skills_router)
352
+ api_router.include_router(plugins_router)
351
353
  api_router.include_router(hooks_router)
352
354
  api_router.include_router(llm_router)
353
355
  api_router.include_router(mcp_router)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import importlib
3
3
  import logging
4
+ from collections.abc import Awaitable, Callable
4
5
  from concurrent.futures import ThreadPoolExecutor
5
6
  from contextlib import suppress
6
7
  from dataclasses import dataclass, field
@@ -1367,6 +1368,12 @@ class WebhookSubscriber(Subscriber):
1367
1368
  session_api_key: str | None = None
1368
1369
  queue: list[Event] = field(default_factory=list)
1369
1370
  _flush_timer: asyncio.Task | None = field(default=None, init=False)
1371
+ # Per-instance sleep seam so tests override delays without patching the
1372
+ # global asyncio.sleep. default_factory (not default) keeps it an instance
1373
+ # attribute, else the function would be descriptor-bound as a method.
1374
+ _sleep: Callable[[float], Awaitable[None]] = field(
1375
+ default_factory=lambda: asyncio.sleep, init=False
1376
+ )
1370
1377
 
1371
1378
  async def __call__(self, event: Event):
1372
1379
  """Add event to queue and post to webhook when buffer size is reached."""
@@ -1437,7 +1444,7 @@ class WebhookSubscriber(Subscriber):
1437
1444
  except Exception as e:
1438
1445
  logger.warning(f"Webhook post attempt {attempt + 1} failed: {e}")
1439
1446
  if attempt < self.spec.num_retries:
1440
- await asyncio.sleep(self.spec.retry_delay)
1447
+ await self._sleep(self.spec.retry_delay)
1441
1448
  else:
1442
1449
  logger.error(
1443
1450
  f"Failed to post events to webhook {events_url} "
@@ -1462,7 +1469,7 @@ class WebhookSubscriber(Subscriber):
1462
1469
  async def _flush_after_delay(self):
1463
1470
  """Wait for flush_delay seconds then flush events if any exist."""
1464
1471
  try:
1465
- await asyncio.sleep(self.spec.flush_delay)
1472
+ await self._sleep(self.spec.flush_delay)
1466
1473
  # Only flush if there are events in the queue
1467
1474
  if self.queue:
1468
1475
  await self._post_events()
@@ -1479,6 +1486,10 @@ class ConversationWebhookSubscriber:
1479
1486
 
1480
1487
  spec: WebhookSpec
1481
1488
  session_api_key: str | None = None
1489
+ # Per-instance sleep seam; see WebhookSubscriber._sleep.
1490
+ _sleep: Callable[[float], Awaitable[None]] = field(
1491
+ default_factory=lambda: asyncio.sleep, init=False
1492
+ )
1482
1493
 
1483
1494
  async def post_conversation_info(self, conversation_info: BaseModel):
1484
1495
  """Post conversation info to the webhook immediately (no batching)."""
@@ -1516,7 +1527,7 @@ class ConversationWebhookSubscriber:
1516
1527
  f"Conversation webhook post attempt {attempt + 1} failed: {e}"
1517
1528
  )
1518
1529
  if attempt < self.spec.num_retries:
1519
- await asyncio.sleep(self.spec.retry_delay)
1530
+ await self._sleep(self.spec.retry_delay)
1520
1531
  else:
1521
1532
  # Log response content for debugging failures
1522
1533
  response_content = (
@@ -605,10 +605,9 @@ class EventService:
605
605
  from callbacks that may run in different threads. Events are emitted through
606
606
  the conversation's normal event flow to ensure they are persisted.
607
607
  """
608
- if self._main_loop and self._main_loop.is_running() and self._conversation:
609
- # Capture conversation reference for closure
610
- conversation = self._conversation
611
-
608
+ main_loop = self._main_loop
609
+ conversation = self._conversation
610
+ if main_loop and main_loop.is_running() and conversation:
612
611
  # Wrap _on_event with lock acquisition to ensure thread-safe access
613
612
  # to conversation state and event log during concurrent operations
614
613
  def locked_on_event():
@@ -617,7 +616,7 @@ class EventService:
617
616
 
618
617
  # Run the locked callback in an executor to ensure the event is
619
618
  # both persisted and sent to WebSocket subscribers
620
- self._main_loop.run_in_executor(None, locked_on_event)
619
+ main_loop.run_in_executor(None, locked_on_event)
621
620
 
622
621
  def _setup_llm_log_streaming(self, agent: AgentBase) -> None:
623
622
  """Configure LLM log callbacks to stream logs via events."""
@@ -633,13 +632,16 @@ class EventService:
633
632
  filename: str, log_data: str, uid=usage_id, model=model_name
634
633
  ) -> None:
635
634
  """Callback to emit LLM completion logs as events."""
636
- event = LLMCompletionLogEvent(
637
- filename=filename,
638
- log_data=log_data,
639
- model_name=model,
640
- usage_id=uid,
641
- )
642
- self._emit_event_from_thread(event)
635
+ try:
636
+ event = LLMCompletionLogEvent(
637
+ filename=filename,
638
+ log_data=log_data,
639
+ model_name=model,
640
+ usage_id=uid,
641
+ )
642
+ self._emit_event_from_thread(event)
643
+ except Exception:
644
+ logger.exception("Failed to emit LLM completion log event")
643
645
 
644
646
  llm.telemetry.set_log_completions_callback(log_callback)
645
647
 
@@ -0,0 +1,333 @@
1
+ """Plugins router for OpenHands Agent Server.
2
+
3
+ HTTP API endpoints for plugin operations. Business logic is delegated to
4
+ ``plugins_service.py``; this module mirrors ``skills_router.py`` and stays
5
+ focused on HTTP concerns. It exposes:
6
+
7
+ * Installed-plugin management — install / list / enable-disable / uninstall /
8
+ refresh — plus listing locally-available plugins.
9
+ * The plugins-only marketplace catalog.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ from fastapi import APIRouter, HTTPException, Path
15
+ from pydantic import BaseModel, Field
16
+
17
+ from openhands.agent_server.plugins_service import (
18
+ MarketplacePluginInfo,
19
+ service_disable_plugin,
20
+ service_enable_plugin,
21
+ service_get_installed_plugin,
22
+ service_get_plugins_marketplace_catalog,
23
+ service_install_plugin,
24
+ service_list_available_plugins,
25
+ service_list_installed_plugins,
26
+ service_uninstall_plugin,
27
+ service_update_plugin,
28
+ )
29
+ from openhands.sdk.extensions.fetch import ExtensionFetchError
30
+ from openhands.sdk.plugin import InstalledPluginInfo, PluginFetchError
31
+
32
+
33
+ plugins_router = APIRouter(prefix="/plugins", tags=["Plugins"])
34
+
35
+ # Kebab-case plugin name — matches the SDK's installed-plugin name rule. Guards
36
+ # the {plugin_name} path parameter against empty strings, path traversal, and
37
+ # invalid characters.
38
+ PLUGIN_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
39
+
40
+ PluginNamePath = Annotated[
41
+ str,
42
+ Path(
43
+ min_length=1,
44
+ max_length=255,
45
+ pattern=PLUGIN_NAME_PATTERN,
46
+ description="Plugin name (lowercase alphanumeric, hyphens)",
47
+ ),
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Models
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class PluginsRequest(BaseModel):
57
+ """Request body for listing locally-available plugins."""
58
+
59
+ load_user: bool = Field(
60
+ default=True, description="Load user plugins (~/.agents/plugins, etc.)"
61
+ )
62
+ load_project: bool = Field(
63
+ default=True, description="Load project plugins from the workspace"
64
+ )
65
+ project_dir: str | None = Field(
66
+ default=None, description="Workspace directory path for project plugins"
67
+ )
68
+
69
+
70
+ class PluginInfo(BaseModel):
71
+ """Summary of an available plugin."""
72
+
73
+ name: str
74
+ version: str = ""
75
+ description: str = ""
76
+
77
+
78
+ class PluginsResponse(BaseModel):
79
+ """Response containing the locally-available plugins."""
80
+
81
+ plugins: list[PluginInfo]
82
+
83
+
84
+ class InstallPluginRequest(BaseModel):
85
+ """Request body for installing a plugin."""
86
+
87
+ source: str = Field(
88
+ min_length=1,
89
+ description=(
90
+ "Plugin source - git URL, GitHub shorthand, or local path. Examples: "
91
+ "'github:OpenHands/extensions/plugins/city-weather', '/path/to/plugin'"
92
+ ),
93
+ )
94
+ ref: str | None = Field(
95
+ default=None, description="Optional branch, tag, or commit to install"
96
+ )
97
+ repo_path: str | None = Field(
98
+ default=None,
99
+ description="Subdirectory path within the repository (for monorepos)",
100
+ )
101
+ force: bool = Field(
102
+ default=False, description="If true, overwrite existing installation"
103
+ )
104
+
105
+
106
+ class InstalledPluginResponse(BaseModel):
107
+ """Response containing installed plugin information."""
108
+
109
+ name: str = Field(description="Plugin name")
110
+ version: str = Field(default="", description="Plugin version")
111
+ description: str = Field(default="", description="Plugin description")
112
+ enabled: bool = Field(default=True, description="Whether the plugin is enabled")
113
+ source: str = Field(description="Original source (e.g., 'github:owner/repo')")
114
+ resolved_ref: str | None = Field(
115
+ default=None, description="Resolved git commit SHA"
116
+ )
117
+ repo_path: str | None = Field(
118
+ default=None, description="Subdirectory path within the repository"
119
+ )
120
+ installed_at: str = Field(description="ISO 8601 timestamp of installation")
121
+ install_path: str = Field(description="Path where the plugin is installed")
122
+
123
+ @classmethod
124
+ def from_plugin_info(cls, info: InstalledPluginInfo) -> "InstalledPluginResponse":
125
+ return cls(
126
+ name=info.name,
127
+ version=info.version,
128
+ description=info.description,
129
+ enabled=info.enabled,
130
+ source=info.source,
131
+ resolved_ref=info.resolved_ref,
132
+ repo_path=info.repo_path,
133
+ installed_at=info.installed_at,
134
+ install_path=str(info.install_path),
135
+ )
136
+
137
+
138
+ class InstalledPluginsListResponse(BaseModel):
139
+ """Response containing the list of installed plugins."""
140
+
141
+ plugins: list[InstalledPluginResponse]
142
+
143
+
144
+ class UpdatePluginStateRequest(BaseModel):
145
+ """Request body for updating plugin state (enable/disable)."""
146
+
147
+ enabled: bool
148
+
149
+
150
+ class UpdatePluginStateResponse(BaseModel):
151
+ """Response from a plugin state update."""
152
+
153
+ name: str
154
+ enabled: bool
155
+
156
+
157
+ class UninstallPluginResponse(BaseModel):
158
+ """Response from a plugin uninstall."""
159
+
160
+ message: str
161
+
162
+
163
+ class UpdatePluginResponse(BaseModel):
164
+ """Response from a plugin refresh/update."""
165
+
166
+ message: str
167
+ plugin: InstalledPluginResponse
168
+
169
+
170
+ class MarketplaceCatalogResponse(BaseModel):
171
+ """Response containing the plugins marketplace catalog."""
172
+
173
+ plugins: list[MarketplacePluginInfo]
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Endpoints
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ @plugins_router.post("", response_model=PluginsResponse)
182
+ def get_plugins(request: PluginsRequest) -> PluginsResponse:
183
+ """List locally-available plugins (enabled installed + user/project dirs).
184
+
185
+ Args:
186
+ request: Which local sources to load.
187
+
188
+ Returns:
189
+ PluginsResponse with available plugin summaries.
190
+ """
191
+ plugins = service_list_available_plugins(
192
+ load_user=request.load_user,
193
+ load_project=request.load_project,
194
+ project_dir=request.project_dir,
195
+ )
196
+ return PluginsResponse(
197
+ plugins=[
198
+ PluginInfo(name=p.name, version=p.version, description=p.description)
199
+ for p in plugins
200
+ ]
201
+ )
202
+
203
+
204
+ @plugins_router.post(
205
+ "/install",
206
+ response_model=InstalledPluginResponse,
207
+ responses={
208
+ 400: {"description": "Failed to fetch plugin source"},
209
+ 409: {"description": "Plugin already installed (use force=true)"},
210
+ 422: {"description": "Invalid plugin (bad name, etc.)"},
211
+ },
212
+ )
213
+ def install_plugin_endpoint(request: InstallPluginRequest) -> InstalledPluginResponse:
214
+ """Install a plugin from a git URL, GitHub shorthand, or local path."""
215
+ try:
216
+ info = service_install_plugin(
217
+ source=request.source,
218
+ ref=request.ref,
219
+ repo_path=request.repo_path,
220
+ force=request.force,
221
+ )
222
+ return InstalledPluginResponse.from_plugin_info(info)
223
+ except FileExistsError:
224
+ raise HTTPException(
225
+ status_code=409,
226
+ detail="Plugin already installed. Use force=true to overwrite.",
227
+ )
228
+ except (PluginFetchError, ExtensionFetchError):
229
+ raise HTTPException(
230
+ status_code=400,
231
+ detail="Failed to fetch plugin source. Check that the source is valid.",
232
+ )
233
+ except ValueError:
234
+ raise HTTPException(
235
+ status_code=422,
236
+ detail="Invalid plugin. Ensure it has a valid kebab-case name.",
237
+ )
238
+
239
+
240
+ @plugins_router.get("/installed", response_model=InstalledPluginsListResponse)
241
+ def list_installed_plugins_endpoint() -> InstalledPluginsListResponse:
242
+ """List all installed plugins (enabled and disabled)."""
243
+ plugins = service_list_installed_plugins()
244
+ return InstalledPluginsListResponse(
245
+ plugins=[InstalledPluginResponse.from_plugin_info(info) for info in plugins]
246
+ )
247
+
248
+
249
+ @plugins_router.get(
250
+ "/installed/{plugin_name}",
251
+ response_model=InstalledPluginResponse,
252
+ responses={404: {"description": "Plugin not installed"}},
253
+ )
254
+ def get_installed_plugin_endpoint(
255
+ plugin_name: PluginNamePath,
256
+ ) -> InstalledPluginResponse:
257
+ """Get information about a specific installed plugin."""
258
+ info = service_get_installed_plugin(name=plugin_name)
259
+ if info is None:
260
+ raise HTTPException(
261
+ status_code=404,
262
+ detail=f"Plugin '{plugin_name}' is not installed",
263
+ )
264
+ return InstalledPluginResponse.from_plugin_info(info)
265
+
266
+
267
+ @plugins_router.patch(
268
+ "/installed/{plugin_name}",
269
+ response_model=UpdatePluginStateResponse,
270
+ responses={404: {"description": "Plugin not installed"}},
271
+ )
272
+ def set_plugin_enabled_endpoint(
273
+ plugin_name: PluginNamePath, request: UpdatePluginStateRequest
274
+ ) -> UpdatePluginStateResponse:
275
+ """Enable or disable an installed plugin."""
276
+ fn = service_enable_plugin if request.enabled else service_disable_plugin
277
+ if not fn(name=plugin_name):
278
+ raise HTTPException(
279
+ status_code=404,
280
+ detail=f"Plugin '{plugin_name}' is not installed",
281
+ )
282
+ return UpdatePluginStateResponse(name=plugin_name, enabled=request.enabled)
283
+
284
+
285
+ @plugins_router.delete(
286
+ "/installed/{plugin_name}",
287
+ response_model=UninstallPluginResponse,
288
+ responses={404: {"description": "Plugin not installed"}},
289
+ )
290
+ def uninstall_plugin_endpoint(plugin_name: PluginNamePath) -> UninstallPluginResponse:
291
+ """Uninstall a plugin by name."""
292
+ if not service_uninstall_plugin(name=plugin_name):
293
+ raise HTTPException(
294
+ status_code=404,
295
+ detail=f"Plugin '{plugin_name}' is not installed",
296
+ )
297
+ return UninstallPluginResponse(message=f"Plugin '{plugin_name}' uninstalled")
298
+
299
+
300
+ @plugins_router.post(
301
+ "/installed/{plugin_name}/refresh",
302
+ response_model=UpdatePluginResponse,
303
+ responses={404: {"description": "Plugin not installed"}},
304
+ )
305
+ def refresh_plugin_endpoint(plugin_name: PluginNamePath) -> UpdatePluginResponse:
306
+ """Refresh an installed plugin to the latest version from its source."""
307
+ info = service_update_plugin(name=plugin_name)
308
+ if info is None:
309
+ raise HTTPException(
310
+ status_code=404,
311
+ detail=f"Plugin '{plugin_name}' is not installed",
312
+ )
313
+ return UpdatePluginResponse(
314
+ message=f"Plugin '{plugin_name}' updated",
315
+ plugin=InstalledPluginResponse.from_plugin_info(info),
316
+ )
317
+
318
+
319
+ @plugins_router.get("/marketplace", response_model=MarketplaceCatalogResponse)
320
+ def get_marketplace_catalog() -> MarketplaceCatalogResponse:
321
+ """Get the plugins marketplace catalog with installation status.
322
+
323
+ Returns the true plugins (entries whose source lives under ``./plugins/``)
324
+ from the OpenHands extensions repository marketplace, each with attachable
325
+ ``PluginSource`` coordinates (``source`` / ``ref`` / ``repo_path``) and an
326
+ ``installed`` flag. This enables the front-end to render a plugins
327
+ marketplace with install/installed state and to attach plugins to
328
+ conversations.
329
+
330
+ Returns:
331
+ MarketplaceCatalogResponse containing the list of available plugins.
332
+ """
333
+ return MarketplaceCatalogResponse(plugins=service_get_plugins_marketplace_catalog())
@@ -0,0 +1,295 @@
1
+ """Plugins service for OpenHands Agent Server.
2
+
3
+ Business logic for two related concerns, both mirroring their skills
4
+ counterparts (``skills_service.py``) so the router stays focused on HTTP:
5
+
6
+ * Installed-plugin management — thin wrappers over the SDK's installed-plugins
7
+ subsystem (``openhands.sdk.plugin``) — plus listing the locally-available
8
+ plugins.
9
+ * The *plugins-only* marketplace catalog. It returns only true plugins from the
10
+ OpenHands extensions marketplace — entries whose ``source`` lives under
11
+ ``./plugins/`` — each carrying attachable ``PluginSource`` coordinates
12
+ (``source`` / ``ref`` / ``repo_path``) plus an ``installed`` flag, so the
13
+ front-end can drive both *attach* and *install* and show install state.
14
+ """
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from time import monotonic
19
+
20
+ from pydantic import BaseModel, ValidationError
21
+
22
+ from openhands.sdk.logger import get_logger
23
+ from openhands.sdk.marketplace import Marketplace
24
+ from openhands.sdk.plugin import (
25
+ InstalledPluginInfo,
26
+ Plugin,
27
+ disable_plugin,
28
+ enable_plugin,
29
+ get_installed_plugin,
30
+ install_plugin,
31
+ list_installed_plugins,
32
+ uninstall_plugin,
33
+ update_plugin,
34
+ )
35
+ from openhands.sdk.skills.skill import (
36
+ DEFAULT_MARKETPLACE_PATH,
37
+ PUBLIC_SKILLS_REF,
38
+ PUBLIC_SKILLS_REPO,
39
+ )
40
+ from openhands.sdk.skills.utils import (
41
+ get_skills_cache_dir,
42
+ update_skills_repository,
43
+ )
44
+ from openhands.sdk.utils.path import to_posix_path
45
+
46
+
47
+ logger = get_logger(__name__)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Installed-plugin management
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def service_install_plugin(
56
+ source: str,
57
+ ref: str | None = None,
58
+ repo_path: str | None = None,
59
+ force: bool = False,
60
+ installed_dir: Path | None = None,
61
+ ) -> InstalledPluginInfo:
62
+ """Install a plugin from a source into the installed-plugins directory."""
63
+ return install_plugin(
64
+ source=source,
65
+ ref=ref,
66
+ repo_path=repo_path,
67
+ force=force,
68
+ installed_dir=installed_dir,
69
+ )
70
+
71
+
72
+ def service_uninstall_plugin(name: str, installed_dir: Path | None = None) -> bool:
73
+ """Uninstall a plugin by name. Returns False if it wasn't installed."""
74
+ return uninstall_plugin(name, installed_dir=installed_dir)
75
+
76
+
77
+ def service_enable_plugin(name: str, installed_dir: Path | None = None) -> bool:
78
+ """Enable an installed plugin. Returns False if it isn't installed."""
79
+ return enable_plugin(name, installed_dir=installed_dir)
80
+
81
+
82
+ def service_disable_plugin(name: str, installed_dir: Path | None = None) -> bool:
83
+ """Disable an installed plugin. Returns False if it isn't installed."""
84
+ return disable_plugin(name, installed_dir=installed_dir)
85
+
86
+
87
+ def service_list_installed_plugins(
88
+ installed_dir: Path | None = None,
89
+ ) -> list[InstalledPluginInfo]:
90
+ """List all installed plugins (enabled and disabled)."""
91
+ return list_installed_plugins(installed_dir=installed_dir)
92
+
93
+
94
+ def service_get_installed_plugin(
95
+ name: str, installed_dir: Path | None = None
96
+ ) -> InstalledPluginInfo | None:
97
+ """Get a specific installed plugin, or None if it isn't installed."""
98
+ return get_installed_plugin(name, installed_dir=installed_dir)
99
+
100
+
101
+ def service_update_plugin(
102
+ name: str, installed_dir: Path | None = None
103
+ ) -> InstalledPluginInfo | None:
104
+ """Update an installed plugin, or None if it isn't installed."""
105
+ return update_plugin(name, installed_dir=installed_dir)
106
+
107
+
108
+ def service_list_available_plugins(
109
+ load_user: bool = True,
110
+ load_project: bool = True,
111
+ project_dir: str | None = None,
112
+ ) -> list[Plugin]:
113
+ """List locally-available plugins (enabled installed + user/project dirs).
114
+
115
+ ``load_available_plugins`` is provided by the "Wire installed + local plugin
116
+ auto-load" ticket (``openhands.sdk.plugin.discovery``). It is imported lazily
117
+ so this module imports cleanly before that ticket is merged; this endpoint
118
+ becomes functional once it lands.
119
+ """
120
+ from openhands.sdk.plugin import load_available_plugins # type: ignore
121
+
122
+ available = load_available_plugins(
123
+ work_dir=project_dir,
124
+ include_user=load_user,
125
+ include_project=load_project,
126
+ )
127
+ return list(available.values())
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Plugins-only marketplace catalog
132
+ # ---------------------------------------------------------------------------
133
+
134
+ # The OpenHands extensions marketplace lists both skills and true plugins under
135
+ # its ``plugins`` array, distinguished only by the entry's source path: true
136
+ # plugins live under ``./plugins/`` while skills live under ``./skills/``. We
137
+ # filter on the raw source for this reason (NOT plugin.json presence, which
138
+ # skills carry too).
139
+ _PLUGINS_SOURCE_PREFIX = "./plugins/"
140
+ # Equivalent prefix when an entry uses a structured source object (github/url)
141
+ # carrying an explicit subpath.
142
+ _PLUGINS_SUBPATH_PREFIX = "plugins/"
143
+
144
+
145
+ class MarketplacePluginInfo(BaseModel):
146
+ """A true plugin in the marketplace catalog, with attach coordinates."""
147
+
148
+ name: str
149
+ description: str | None
150
+ source: str
151
+ ref: str | None = None
152
+ repo_path: str | None = None
153
+ installed: bool
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Marketplace catalog cache
158
+ # ---------------------------------------------------------------------------
159
+ # Mirrors the skills marketplace cache: each call would otherwise trigger a git
160
+ # fetch (network-bound, multiple seconds). A short TTL avoids that on every tab
161
+ # open. Only the catalog structure is cached; ``installed`` is always derived
162
+ # fresh from the local FS. Unlike the skills cache, an *empty* result (e.g. a
163
+ # transient fetch failure) is NOT cached, so one flaky fetch does not blank the
164
+ # catalog for the whole TTL.
165
+ #
166
+ # Type: (timestamp, list-of-(name, description, source, ref, repo_path)) or None
167
+ _PluginCatalogEntry = tuple[str, str | None, str, str | None, str | None]
168
+ _plugin_catalog_cache: tuple[float, list[_PluginCatalogEntry]] | None = None
169
+ _PLUGIN_CATALOG_TTL_SECONDS = 300 # 5 minutes
170
+
171
+
172
+ def service_get_plugins_marketplace_catalog(
173
+ marketplace_path: str = DEFAULT_MARKETPLACE_PATH,
174
+ installed_dir: Path | None = None,
175
+ ) -> list[MarketplacePluginInfo]:
176
+ """Get the plugins-only marketplace catalog with installation status.
177
+
178
+ Loads the marketplace JSON from the public extensions repository, keeps only
179
+ true plugins (source under ``./plugins/``), and enriches each with its
180
+ attachable coordinates and installation status.
181
+
182
+ The catalog structure is cached for ``_PLUGIN_CATALOG_TTL_SECONDS`` to avoid
183
+ a git fetch on every call. The ``installed`` field is always resolved fresh
184
+ from the local FS.
185
+
186
+ Args:
187
+ marketplace_path: Relative path to the marketplace JSON file.
188
+ installed_dir: Directory of installed plugins to check status against.
189
+ Defaults to ``~/.openhands/plugins/installed/``.
190
+
191
+ Returns:
192
+ List of MarketplacePluginInfo with plugin details and install status.
193
+ """
194
+ global _plugin_catalog_cache
195
+
196
+ now = monotonic()
197
+ if (
198
+ _plugin_catalog_cache is not None
199
+ and now - _plugin_catalog_cache[0] < _PLUGIN_CATALOG_TTL_SECONDS
200
+ ):
201
+ entries = _plugin_catalog_cache[1]
202
+ else:
203
+ entries = _fetch_plugin_catalog_entries(marketplace_path)
204
+ # Only cache non-empty results so a transient fetch failure does not
205
+ # blank the catalog for the whole TTL.
206
+ if entries:
207
+ _plugin_catalog_cache = (now, entries)
208
+
209
+ # Always-fresh installed check — local FS scan, not a network call.
210
+ installed_names = {
211
+ p.name for p in list_installed_plugins(installed_dir=installed_dir)
212
+ }
213
+ return [
214
+ MarketplacePluginInfo(
215
+ name=name,
216
+ description=desc,
217
+ source=src,
218
+ ref=ref,
219
+ repo_path=repo_path,
220
+ installed=name in installed_names,
221
+ )
222
+ for name, desc, src, ref, repo_path in entries
223
+ ]
224
+
225
+
226
+ def _is_true_plugin(raw_source: object) -> bool:
227
+ """Whether a marketplace entry's *raw* source points at a true plugin.
228
+
229
+ Must run on the raw source BEFORE ``resolve_plugin_source`` rewrites a
230
+ relative path into an absolute one (which drops the ``./plugins/`` prefix).
231
+ String sources are true plugins when under ``./plugins/``; structured
232
+ source objects when their subpath is under ``plugins/``. Skills (``./skills/``)
233
+ are excluded.
234
+ """
235
+ if isinstance(raw_source, str):
236
+ return raw_source.startswith(_PLUGINS_SOURCE_PREFIX)
237
+ subpath = getattr(raw_source, "path", None) or ""
238
+ return subpath.startswith(_PLUGINS_SUBPATH_PREFIX)
239
+
240
+
241
+ def _fetch_plugin_catalog_entries(marketplace_path: str) -> list[_PluginCatalogEntry]:
242
+ """Fetch the marketplace and keep only true plugins.
243
+
244
+ Slow path: git fetch + read the marketplace JSON. Returns
245
+ ``(name, description, source, ref, repo_path)`` tuples, or an empty list on
246
+ error.
247
+ """
248
+ cache_dir = get_skills_cache_dir()
249
+ repo_path = update_skills_repository(
250
+ PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
251
+ )
252
+
253
+ if repo_path is None:
254
+ logger.warning("Failed to access public extensions repository")
255
+ return []
256
+
257
+ # Primary loader: ``Marketplace.load`` discovers the manifest in
258
+ # ``.plugin/`` or ``.claude-plugin/`` — the real OpenHands/extensions layout,
259
+ # where ``.plugin/marketplace.json`` points at the published catalog. We must
260
+ # NOT gate on ``marketplace_path`` (``marketplaces/default.json``) existing
261
+ # first: that file is absent in the current extensions repo, so an early
262
+ # return there would blank the catalog even though the manifest is present.
263
+ # The explicit ``marketplace_path`` file is only a fallback for layouts that
264
+ # ship it instead of a ``.plugin/`` manifest.
265
+ try:
266
+ marketplace = Marketplace.load(repo_path)
267
+ except (FileNotFoundError, ValueError) as e:
268
+ marketplace_file = repo_path / marketplace_path
269
+ if not marketplace_file.exists():
270
+ logger.warning(
271
+ f"Failed to load marketplace via manifest discovery ({e}); "
272
+ f"fallback file not found: {marketplace_file}"
273
+ )
274
+ return []
275
+ try:
276
+ with open(marketplace_file, encoding="utf-8") as f:
277
+ data = json.load(f)
278
+ marketplace = Marketplace.model_validate(
279
+ {**data, "path": to_posix_path(repo_path)}
280
+ )
281
+ except (json.JSONDecodeError, ValidationError, OSError) as e2:
282
+ logger.warning(f"Failed to load marketplace: {e}, {e2}")
283
+ return []
284
+
285
+ entries: list[_PluginCatalogEntry] = []
286
+ for plugin in marketplace.plugins:
287
+ if not _is_true_plugin(plugin.source):
288
+ continue
289
+ # Resolve to attachable coordinates. For a local ./plugins/<name> entry
290
+ # this yields an absolute path with ref/repo_path None; structured
291
+ # github/url sources yield their ref + subpath.
292
+ source, ref, repo_path = marketplace.resolve_plugin_source(plugin)
293
+ entries.append((plugin.name, plugin.description, source, ref, repo_path))
294
+
295
+ return entries
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.29.2
3
+ Version: 1.29.3
4
4
  Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
@@ -28,6 +28,8 @@ pyproject.toml
28
28
  ./openhands/agent_server/middleware.py
29
29
  ./openhands/agent_server/models.py
30
30
  ./openhands/agent_server/openapi.py
31
+ ./openhands/agent_server/plugins_router.py
32
+ ./openhands/agent_server/plugins_service.py
31
33
  ./openhands/agent_server/profiles_router.py
32
34
  ./openhands/agent_server/pub_sub.py
33
35
  ./openhands/agent_server/py.typed
@@ -84,6 +86,8 @@ openhands/agent_server/mcp_router.py
84
86
  openhands/agent_server/middleware.py
85
87
  openhands/agent_server/models.py
86
88
  openhands/agent_server/openapi.py
89
+ openhands/agent_server/plugins_router.py
90
+ openhands/agent_server/plugins_service.py
87
91
  openhands/agent_server/profiles_router.py
88
92
  openhands/agent_server/pub_sub.py
89
93
  openhands/agent_server/py.typed
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.29.2"
3
+ version = "1.29.3"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"