openhands-agent-server 1.40.0__tar.gz → 1.41.0__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.
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/_secrets_exposure.py +19 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/agent_profiles_router.py +16 -33
- openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/__init__.py +44 -0
- openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/installed.py +403 -0
- openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/manifest.py +166 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/config.py +17 -2
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_service.py +147 -34
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_oauth_store.py +4 -1
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_router.py +0 -35
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/models.py +1 -33
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/profiles_router.py +7 -25
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/settings_router.py +43 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/SOURCES.txt +6 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/pyproject.toml +1 -1
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/_secret_redaction.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/api.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/credential_binding.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/event_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/init_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/sub_agents_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/__init__.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/factory.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/models.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/policy.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sink.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.41.0
|
|
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
|
|
@@ -120,3 +120,22 @@ def translate_missing_cipher() -> Iterator[None]:
|
|
|
120
120
|
),
|
|
121
121
|
)
|
|
122
122
|
raise
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def store_errors() -> Iterator[None]:
|
|
127
|
+
"""Map profile-store errors (``LLMProfileStore``/``AgentProfileStore``) to
|
|
128
|
+
HTTP responses. Shared by the settings, profiles, and agent-profiles
|
|
129
|
+
routers."""
|
|
130
|
+
try:
|
|
131
|
+
yield
|
|
132
|
+
except TimeoutError:
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
135
|
+
detail="Profile store is busy. Please retry.",
|
|
136
|
+
)
|
|
137
|
+
except ValueError as e:
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
140
|
+
detail=str(e),
|
|
141
|
+
)
|
|
@@ -12,14 +12,16 @@ MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnosti
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
|
-
from collections.abc import Iterator
|
|
16
|
-
from contextlib import contextmanager
|
|
17
15
|
from typing import Annotated, Any
|
|
18
16
|
|
|
19
17
|
from fastapi import APIRouter, HTTPException, Path, Request, status
|
|
20
18
|
from pydantic import BaseModel, Field, ValidationError
|
|
21
19
|
|
|
22
|
-
from openhands.agent_server._secrets_exposure import
|
|
20
|
+
from openhands.agent_server._secrets_exposure import (
|
|
21
|
+
get_cipher,
|
|
22
|
+
get_config,
|
|
23
|
+
store_errors,
|
|
24
|
+
)
|
|
23
25
|
from openhands.agent_server.persistence import (
|
|
24
26
|
PersistedSettings,
|
|
25
27
|
get_agent_profile_store,
|
|
@@ -105,25 +107,6 @@ class RenameAgentProfileRequest(BaseModel):
|
|
|
105
107
|
)
|
|
106
108
|
|
|
107
109
|
|
|
108
|
-
@contextmanager
|
|
109
|
-
def _store_errors() -> Iterator[None]:
|
|
110
|
-
"""Map ``AgentProfileStore`` errors to HTTP responses.
|
|
111
|
-
|
|
112
|
-
Mirrors ``profiles_router._store_errors``: ``TimeoutError`` and
|
|
113
|
-
``ValueError`` only. ``FileNotFoundError`` / ``FileExistsError`` are handled
|
|
114
|
-
inline per-endpoint so each gets a clean, resource-specific message.
|
|
115
|
-
"""
|
|
116
|
-
try:
|
|
117
|
-
yield
|
|
118
|
-
except TimeoutError:
|
|
119
|
-
raise HTTPException(
|
|
120
|
-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
121
|
-
detail="Agent profile store is busy. Please retry.",
|
|
122
|
-
)
|
|
123
|
-
except ValueError as e:
|
|
124
|
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
125
|
-
|
|
126
|
-
|
|
127
110
|
def _llm_has_real_config(llm: LLM) -> bool:
|
|
128
111
|
"""True when ``llm`` carries real, user-provided configuration.
|
|
129
112
|
|
|
@@ -173,7 +156,7 @@ def _seed_default_llm_profile(llm: LLM, cipher: Cipher | None) -> str:
|
|
|
173
156
|
silently clobber it.
|
|
174
157
|
"""
|
|
175
158
|
llm_store = get_llm_profile_store()
|
|
176
|
-
with
|
|
159
|
+
with store_errors():
|
|
177
160
|
try:
|
|
178
161
|
llm_store.load(SEED_PROFILE_NAME, cipher=cipher)
|
|
179
162
|
return SEED_PROFILE_NAME
|
|
@@ -227,7 +210,7 @@ def _seed_default_profile(
|
|
|
227
210
|
The lock spans empty-check + save + pointer write so concurrent first
|
|
228
211
|
requests seed exactly once and the pointer matches the persisted id.
|
|
229
212
|
"""
|
|
230
|
-
with
|
|
213
|
+
with store_errors(), store.lock():
|
|
231
214
|
# Double-checked under the lock: a concurrent first request may have
|
|
232
215
|
# already seeded (the outer emptiness check in the list endpoint is
|
|
233
216
|
# unlocked).
|
|
@@ -261,7 +244,7 @@ def _seed_default_profile(
|
|
|
261
244
|
|
|
262
245
|
def _summary_id_for_name(store: AgentProfileStore, name: str) -> str | None:
|
|
263
246
|
"""Return the stable id of the profile stored under ``name``, if present."""
|
|
264
|
-
with
|
|
247
|
+
with store_errors():
|
|
265
248
|
for summary in store.list_summaries():
|
|
266
249
|
if summary.get("name") == name:
|
|
267
250
|
sid = summary.get("id")
|
|
@@ -282,14 +265,14 @@ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
|
|
|
282
265
|
settings = settings_store.load() or PersistedSettings()
|
|
283
266
|
|
|
284
267
|
store = get_agent_profile_store()
|
|
285
|
-
with
|
|
268
|
+
with store_errors():
|
|
286
269
|
existing = store.list()
|
|
287
270
|
|
|
288
271
|
if not existing and settings.active_agent_profile_id is None:
|
|
289
272
|
_seed_default_profile(store, request, settings, get_cipher(request))
|
|
290
273
|
settings = settings_store.load() or settings
|
|
291
274
|
|
|
292
|
-
with
|
|
275
|
+
with store_errors():
|
|
293
276
|
summaries = store.list_summaries()
|
|
294
277
|
|
|
295
278
|
return AgentProfileListResponse(
|
|
@@ -308,7 +291,7 @@ async def get_agent_profile(name: ProfileName) -> AgentProfileDetailResponse:
|
|
|
308
291
|
"""
|
|
309
292
|
store = get_agent_profile_store()
|
|
310
293
|
try:
|
|
311
|
-
with
|
|
294
|
+
with store_errors():
|
|
312
295
|
profile = store.load(name)
|
|
313
296
|
except FileNotFoundError:
|
|
314
297
|
raise HTTPException(
|
|
@@ -361,7 +344,7 @@ async def save_agent_profile(
|
|
|
361
344
|
# holds the store lock across read + mint + save so two concurrent creates
|
|
362
345
|
# of the same new name can't both mint an id and clobber each other.
|
|
363
346
|
try:
|
|
364
|
-
with
|
|
347
|
+
with store_errors():
|
|
365
348
|
save_profile_preserving_identity(
|
|
366
349
|
store, profile, max_profiles=MAX_AGENT_PROFILES
|
|
367
350
|
)
|
|
@@ -392,7 +375,7 @@ async def delete_agent_profile(
|
|
|
392
375
|
store = get_agent_profile_store()
|
|
393
376
|
deleted_id = _summary_id_for_name(store, name)
|
|
394
377
|
|
|
395
|
-
with
|
|
378
|
+
with store_errors():
|
|
396
379
|
store.delete(name)
|
|
397
380
|
|
|
398
381
|
if deleted_id is not None:
|
|
@@ -428,7 +411,7 @@ async def rename_agent_profile(
|
|
|
428
411
|
"""
|
|
429
412
|
store = get_agent_profile_store()
|
|
430
413
|
try:
|
|
431
|
-
with
|
|
414
|
+
with store_errors():
|
|
432
415
|
store.rename(name, body.new_name)
|
|
433
416
|
except FileNotFoundError:
|
|
434
417
|
raise HTTPException(
|
|
@@ -462,7 +445,7 @@ async def activate_agent_profile(
|
|
|
462
445
|
creation-time-only contract). Returns 404 if no stored profile has that id.
|
|
463
446
|
"""
|
|
464
447
|
store = get_agent_profile_store()
|
|
465
|
-
with
|
|
448
|
+
with store_errors():
|
|
466
449
|
known_ids = {
|
|
467
450
|
str(s["id"]) for s in store.list_summaries() if s.get("id") is not None
|
|
468
451
|
}
|
|
@@ -515,7 +498,7 @@ async def materialize_agent_profile(
|
|
|
515
498
|
"""
|
|
516
499
|
store = get_agent_profile_store()
|
|
517
500
|
try:
|
|
518
|
-
with
|
|
501
|
+
with store_errors():
|
|
519
502
|
profile = store.load(name)
|
|
520
503
|
except FileNotFoundError:
|
|
521
504
|
raise HTTPException(
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Canvas Extensions: installable UI bundles that contribute pages to Canvas."""
|
|
2
|
+
|
|
3
|
+
from openhands.agent_server.canvas_extensions.installed import (
|
|
4
|
+
CanvasExtensionUpdateCheck,
|
|
5
|
+
InstalledCanvasExtensionInfo,
|
|
6
|
+
apply_canvas_extension_update,
|
|
7
|
+
check_canvas_extension_update,
|
|
8
|
+
disable_canvas_extension,
|
|
9
|
+
enable_canvas_extension,
|
|
10
|
+
get_installed_canvas_extension,
|
|
11
|
+
get_installed_canvas_extensions_dir,
|
|
12
|
+
install_canvas_extension,
|
|
13
|
+
list_installed_canvas_extensions,
|
|
14
|
+
load_installed_canvas_extensions,
|
|
15
|
+
uninstall_canvas_extension,
|
|
16
|
+
)
|
|
17
|
+
from openhands.agent_server.canvas_extensions.manifest import (
|
|
18
|
+
MANIFEST_FILENAME,
|
|
19
|
+
CanvasExtensionContributes,
|
|
20
|
+
CanvasExtensionManifest,
|
|
21
|
+
CanvasExtensionPage,
|
|
22
|
+
resolve_entrypoint,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CanvasExtensionManifest",
|
|
28
|
+
"CanvasExtensionContributes",
|
|
29
|
+
"CanvasExtensionPage",
|
|
30
|
+
"MANIFEST_FILENAME",
|
|
31
|
+
"resolve_entrypoint",
|
|
32
|
+
"InstalledCanvasExtensionInfo",
|
|
33
|
+
"install_canvas_extension",
|
|
34
|
+
"uninstall_canvas_extension",
|
|
35
|
+
"enable_canvas_extension",
|
|
36
|
+
"disable_canvas_extension",
|
|
37
|
+
"list_installed_canvas_extensions",
|
|
38
|
+
"load_installed_canvas_extensions",
|
|
39
|
+
"get_installed_canvas_extension",
|
|
40
|
+
"get_installed_canvas_extensions_dir",
|
|
41
|
+
"CanvasExtensionUpdateCheck",
|
|
42
|
+
"check_canvas_extension_update",
|
|
43
|
+
"apply_canvas_extension_update",
|
|
44
|
+
]
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Installed Canvas Extensions.
|
|
2
|
+
|
|
3
|
+
Built on ``openhands.sdk.extensions.installation``, shared with Plugins/
|
|
4
|
+
Skills, with two behaviors specific to this module:
|
|
5
|
+
|
|
6
|
+
Disabled by default -- ``InstallationInfo.enabled`` defaults to ``True``
|
|
7
|
+
and neither ``InstallationManager.install()`` nor its directory discovery
|
|
8
|
+
override that for a new entry, so every write path that can create one
|
|
9
|
+
forces ``enabled=False`` as an explicit post-write step.
|
|
10
|
+
|
|
11
|
+
Staged refresh -- ``check``/``apply`` replace ``update()``/
|
|
12
|
+
``install(force=True)``, which rmtree+copytree straight onto the live
|
|
13
|
+
install path with no staging directory. ``check`` fetches and validates
|
|
14
|
+
into ``.staging/`` without touching the active install; ``apply`` swaps it
|
|
15
|
+
in via two atomic renames (POSIX can't atomically replace a non-empty
|
|
16
|
+
directory in one rename), rolling back if the second fails.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
24
|
+
|
|
25
|
+
from openhands.agent_server.canvas_extensions.manifest import (
|
|
26
|
+
MANIFEST_FILENAME,
|
|
27
|
+
CanvasExtensionManifest,
|
|
28
|
+
resolve_entrypoint,
|
|
29
|
+
)
|
|
30
|
+
from openhands.sdk.extensions.fetch import fetch_with_resolution
|
|
31
|
+
from openhands.sdk.extensions.installation import (
|
|
32
|
+
InstallationInfo,
|
|
33
|
+
InstallationInterface,
|
|
34
|
+
InstallationManager,
|
|
35
|
+
InstallationMetadata,
|
|
36
|
+
)
|
|
37
|
+
from openhands.sdk.extensions.installation.manager import DEFAULT_CACHE_DIR
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Matches the InstalledPluginInfo naming convention.
|
|
41
|
+
InstalledCanvasExtensionInfo = InstallationInfo
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_installed_canvas_extensions_dir() -> Path:
|
|
45
|
+
"""Get the default directory for installed canvas extensions."""
|
|
46
|
+
return Path.home() / ".openhands" / "canvas-extensions" / "installed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CanvasExtensionInstallationInterface(
|
|
50
|
+
InstallationInterface[CanvasExtensionManifest]
|
|
51
|
+
):
|
|
52
|
+
@staticmethod
|
|
53
|
+
def load_from_dir(extension_dir: Path) -> CanvasExtensionManifest:
|
|
54
|
+
manifest_path = extension_dir / MANIFEST_FILENAME
|
|
55
|
+
manifest = CanvasExtensionManifest.model_validate_json(
|
|
56
|
+
manifest_path.read_text()
|
|
57
|
+
)
|
|
58
|
+
# Containment must hold too -- a parseable manifest alone isn't enough.
|
|
59
|
+
resolve_entrypoint(manifest, extension_dir)
|
|
60
|
+
return manifest
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_installed_dir(installed_dir: Path | None) -> Path:
|
|
64
|
+
return (
|
|
65
|
+
installed_dir
|
|
66
|
+
if installed_dir is not None
|
|
67
|
+
else get_installed_canvas_extensions_dir()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _manager(installed_dir: Path) -> InstallationManager[CanvasExtensionManifest]:
|
|
72
|
+
return InstallationManager(
|
|
73
|
+
installation_dir=installed_dir,
|
|
74
|
+
installation_interface=CanvasExtensionInstallationInterface(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tracked_names(installed_dir: Path) -> set[str]:
|
|
79
|
+
"""Names with a real, valid tracked entry, not just a metadata key.
|
|
80
|
+
|
|
81
|
+
A stale record with no matching directory must not count as "already
|
|
82
|
+
installed" -- otherwise a real install of that name would wrongly
|
|
83
|
+
inherit its state as if this were a force-reinstall.
|
|
84
|
+
"""
|
|
85
|
+
if not installed_dir.exists():
|
|
86
|
+
return set()
|
|
87
|
+
metadata = InstallationMetadata.load_from_dir(installed_dir)
|
|
88
|
+
return {info.name for info in metadata.validate_tracked(installed_dir)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _force_disable_new(
|
|
92
|
+
manager: InstallationManager[CanvasExtensionManifest],
|
|
93
|
+
info: InstallationInfo,
|
|
94
|
+
pre_existing: set[str],
|
|
95
|
+
) -> InstallationInfo:
|
|
96
|
+
"""Force a newly created tracked entry to ``enabled=False``.
|
|
97
|
+
|
|
98
|
+
``pre_existing`` distinguishes a genuinely new entry from a
|
|
99
|
+
force-reinstall, whose prior enabled state is already preserved.
|
|
100
|
+
"""
|
|
101
|
+
if info.name in pre_existing or not info.enabled:
|
|
102
|
+
return info
|
|
103
|
+
manager.disable(info.name)
|
|
104
|
+
info.enabled = False
|
|
105
|
+
return info
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install_canvas_extension(
|
|
109
|
+
source: str,
|
|
110
|
+
ref: str | None = None,
|
|
111
|
+
repo_path: str | None = None,
|
|
112
|
+
installed_dir: Path | None = None,
|
|
113
|
+
force: bool = False,
|
|
114
|
+
) -> InstalledCanvasExtensionInfo:
|
|
115
|
+
"""Install a canvas extension from a source.
|
|
116
|
+
|
|
117
|
+
A newly created entry always lands disabled, regardless of what the
|
|
118
|
+
caller passes in.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
source: ``"github:owner/repo"``, a git URL, or a local path.
|
|
122
|
+
ref: Optional branch, tag, or commit to install.
|
|
123
|
+
repo_path: Subdirectory within the repository (for monorepos).
|
|
124
|
+
installed_dir: Defaults to ``~/.openhands/canvas-extensions/installed/``.
|
|
125
|
+
force: If True, overwrite an existing installation.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
InstalledCanvasExtensionInfo for the installed extension.
|
|
129
|
+
"""
|
|
130
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
131
|
+
manager = _manager(installed_dir)
|
|
132
|
+
pre_existing = _tracked_names(installed_dir)
|
|
133
|
+
info = manager.install(source, ref=ref, repo_path=repo_path, force=force)
|
|
134
|
+
return _force_disable_new(manager, info, pre_existing)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def uninstall_canvas_extension(name: str, installed_dir: Path | None = None) -> bool:
|
|
138
|
+
"""Uninstall a canvas extension by name.
|
|
139
|
+
|
|
140
|
+
Also drops any staged, not-yet-applied update for *name*, so refreshing
|
|
141
|
+
a since-uninstalled name never leaves an orphaned staging slot behind.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if uninstalled, False if it wasn't installed.
|
|
145
|
+
"""
|
|
146
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
147
|
+
uninstalled = _manager(installed_dir).uninstall(name)
|
|
148
|
+
if uninstalled:
|
|
149
|
+
shutil.rmtree(_staging_root(installed_dir) / name, ignore_errors=True)
|
|
150
|
+
return uninstalled
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def enable_canvas_extension(name: str, installed_dir: Path | None = None) -> bool:
|
|
154
|
+
"""Enable an installed canvas extension by name."""
|
|
155
|
+
return _manager(_resolve_installed_dir(installed_dir)).enable(name)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def disable_canvas_extension(name: str, installed_dir: Path | None = None) -> bool:
|
|
159
|
+
"""Disable an installed canvas extension by name."""
|
|
160
|
+
return _manager(_resolve_installed_dir(installed_dir)).disable(name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_installed_canvas_extensions(
|
|
164
|
+
installed_dir: Path | None = None,
|
|
165
|
+
) -> list[InstalledCanvasExtensionInfo]:
|
|
166
|
+
"""List all installed canvas extensions.
|
|
167
|
+
|
|
168
|
+
Self-healing like ``InstallationManager.list_installed()``; directories
|
|
169
|
+
discovered this way also land disabled.
|
|
170
|
+
"""
|
|
171
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
172
|
+
manager = _manager(installed_dir)
|
|
173
|
+
pre_existing = _tracked_names(installed_dir)
|
|
174
|
+
infos = manager.list_installed()
|
|
175
|
+
return [_force_disable_new(manager, info, pre_existing) for info in infos]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_installed_canvas_extensions(
|
|
179
|
+
installed_dir: Path | None = None,
|
|
180
|
+
) -> list[CanvasExtensionManifest]:
|
|
181
|
+
"""Load all enabled canvas extensions' manifests.
|
|
182
|
+
|
|
183
|
+
Runs through ``list_installed_canvas_extensions`` first so discovery
|
|
184
|
+
is force-disabled before anything is loaded -- see the module
|
|
185
|
+
docstring. Mirrors ``InstallationManager.load_installed()``'s own
|
|
186
|
+
enabled-filter/load logic on top of the corrected info list.
|
|
187
|
+
"""
|
|
188
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
189
|
+
manager = _manager(installed_dir)
|
|
190
|
+
manifests: list[CanvasExtensionManifest] = []
|
|
191
|
+
for info in list_installed_canvas_extensions(installed_dir):
|
|
192
|
+
if not info.enabled:
|
|
193
|
+
continue
|
|
194
|
+
extension_path = installed_dir / info.name
|
|
195
|
+
if extension_path.exists():
|
|
196
|
+
manifests.append(
|
|
197
|
+
manager.installation_interface.load_from_dir(extension_path)
|
|
198
|
+
)
|
|
199
|
+
return manifests
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_installed_canvas_extension(
|
|
203
|
+
name: str, installed_dir: Path | None = None
|
|
204
|
+
) -> InstalledCanvasExtensionInfo | None:
|
|
205
|
+
"""Get information about a specific installed canvas extension."""
|
|
206
|
+
return _manager(_resolve_installed_dir(installed_dir)).get(name)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CanvasExtensionUpdateCheck(BaseModel):
|
|
210
|
+
"""Result of ``check_canvas_extension_update``.
|
|
211
|
+
|
|
212
|
+
The active install is untouched; only ``.staging/`` is written. Pass
|
|
213
|
+
``resolved_ref`` back to ``apply_canvas_extension_update`` to confirm
|
|
214
|
+
and swap this exact staged content into place.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
requested_ref: str | None = Field(
|
|
218
|
+
description="Ref the staged fetch was resolved against"
|
|
219
|
+
)
|
|
220
|
+
resolved_ref: str | None = Field(
|
|
221
|
+
description="Commit SHA the staged content was resolved to"
|
|
222
|
+
)
|
|
223
|
+
validated: bool = Field(
|
|
224
|
+
description="Whether staged content passed validation; only True may be applied"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _staging_root(installed_dir: Path) -> Path:
|
|
229
|
+
return installed_dir / ".staging"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _staged_path(installed_dir: Path, name: str, resolved_ref: str | None) -> Path:
|
|
233
|
+
"""Path to the staging slot for (*name*, *resolved_ref*).
|
|
234
|
+
|
|
235
|
+
*resolved_ref* is untrusted (``apply_canvas_extension_update`` takes it
|
|
236
|
+
as a caller-supplied argument), so it's rejected if it could escape the
|
|
237
|
+
staging directory -- same check as ``entrypoint`` in manifest.py.
|
|
238
|
+
"""
|
|
239
|
+
if resolved_ref is not None and (
|
|
240
|
+
not resolved_ref
|
|
241
|
+
or resolved_ref.startswith("/")
|
|
242
|
+
or ".." in Path(resolved_ref).parts
|
|
243
|
+
):
|
|
244
|
+
raise ValueError(f"Invalid resolved_ref: {resolved_ref!r}")
|
|
245
|
+
return _staging_root(installed_dir) / name / (resolved_ref or "local")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _stage_fetched_content(
|
|
249
|
+
installed_dir: Path, name: str, resolved_ref: str | None, fetched_path: Path
|
|
250
|
+
) -> Path:
|
|
251
|
+
"""Copy fetched content into a clean staging slot for *name*.
|
|
252
|
+
|
|
253
|
+
Clears any previously staged candidate first -- at most one is kept
|
|
254
|
+
per extension at a time.
|
|
255
|
+
"""
|
|
256
|
+
slot_root = _staging_root(installed_dir) / name
|
|
257
|
+
shutil.rmtree(slot_root, ignore_errors=True)
|
|
258
|
+
staged_path = _staged_path(installed_dir, name, resolved_ref)
|
|
259
|
+
# symlinks=True: dereferencing here would copy a malicious symlink's
|
|
260
|
+
# target in as a plain file, defeating containment validation below.
|
|
261
|
+
shutil.copytree(fetched_path, staged_path, symlinks=True)
|
|
262
|
+
return staged_path
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _load_validated_manifest(
|
|
266
|
+
staged_path: Path, expected_name: str
|
|
267
|
+
) -> CanvasExtensionManifest:
|
|
268
|
+
"""Load and validate a staged extension's manifest.
|
|
269
|
+
|
|
270
|
+
Raises if the manifest is malformed, its entrypoint escapes the package
|
|
271
|
+
root, or its name no longer matches *expected_name*.
|
|
272
|
+
"""
|
|
273
|
+
manifest = CanvasExtensionInstallationInterface.load_from_dir(staged_path)
|
|
274
|
+
if manifest.name != expected_name:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"Staged content for {expected_name!r} declares a different "
|
|
277
|
+
f"name {manifest.name!r}; refusing to treat it as an update"
|
|
278
|
+
)
|
|
279
|
+
return manifest
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def check_canvas_extension_update(
|
|
283
|
+
name: str, installed_dir: Path | None = None
|
|
284
|
+
) -> CanvasExtensionUpdateCheck | None:
|
|
285
|
+
"""Check for an update to *name*, staging and validating it.
|
|
286
|
+
|
|
287
|
+
Re-fetches the tracked source at its original ref (never "latest")
|
|
288
|
+
into ``.staging/``; the active install is untouched. See
|
|
289
|
+
``apply_canvas_extension_update`` for the step that swaps it into
|
|
290
|
+
place.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
name: Name of the installed extension to check.
|
|
294
|
+
installed_dir: Defaults to ``~/.openhands/canvas-extensions/installed/``.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
None if not installed, else a result -- only apply when ``validated``.
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If *name* is not valid kebab-case.
|
|
301
|
+
ExtensionFetchError: If fetching the tracked source fails.
|
|
302
|
+
"""
|
|
303
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
304
|
+
manager = _manager(installed_dir)
|
|
305
|
+
current_info = manager.get(name)
|
|
306
|
+
if current_info is None:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
fetched_path, resolved_ref = fetch_with_resolution(
|
|
310
|
+
source=current_info.source,
|
|
311
|
+
cache_dir=DEFAULT_CACHE_DIR,
|
|
312
|
+
ref=current_info.requested_ref,
|
|
313
|
+
repo_path=current_info.repo_path,
|
|
314
|
+
update=True,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
staged_path = _stage_fetched_content(
|
|
318
|
+
installed_dir, name, resolved_ref, fetched_path
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
_load_validated_manifest(staged_path, name)
|
|
322
|
+
validated = True
|
|
323
|
+
except (ValidationError, ValueError, OSError):
|
|
324
|
+
validated = False
|
|
325
|
+
shutil.rmtree(staged_path.parent, ignore_errors=True)
|
|
326
|
+
|
|
327
|
+
return CanvasExtensionUpdateCheck(
|
|
328
|
+
requested_ref=current_info.requested_ref,
|
|
329
|
+
resolved_ref=resolved_ref,
|
|
330
|
+
validated=validated,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def apply_canvas_extension_update(
|
|
335
|
+
name: str,
|
|
336
|
+
resolved_ref: str | None,
|
|
337
|
+
enabled: bool,
|
|
338
|
+
installed_dir: Path | None = None,
|
|
339
|
+
) -> InstalledCanvasExtensionInfo | None:
|
|
340
|
+
"""Apply a previously checked and validated update.
|
|
341
|
+
|
|
342
|
+
*resolved_ref* must match a staged candidate already validated by
|
|
343
|
+
``check_canvas_extension_update``, reconfirming what's being applied.
|
|
344
|
+
Atomically swaps staged content into the active install path;
|
|
345
|
+
*enabled* is always applied explicitly, never inherited.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
name: Name of the installed extension to update.
|
|
349
|
+
resolved_ref: The ``resolved_ref`` from a prior, validated check.
|
|
350
|
+
enabled: Enabled state to apply to the new bundle.
|
|
351
|
+
installed_dir: Defaults to ``~/.openhands/canvas-extensions/installed/``.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
None if not installed, else the InstallationInfo for the new bundle.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ValueError: If *name* is invalid, *resolved_ref* could escape the
|
|
358
|
+
staging directory, or no validated staged candidate matches it
|
|
359
|
+
-- call check first.
|
|
360
|
+
"""
|
|
361
|
+
installed_dir = _resolve_installed_dir(installed_dir)
|
|
362
|
+
manager = _manager(installed_dir)
|
|
363
|
+
current_info = manager.get(name)
|
|
364
|
+
if current_info is None:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
staged_path = _staged_path(installed_dir, name, resolved_ref)
|
|
368
|
+
if not staged_path.is_dir():
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"No validated staged update found for {name!r} at ref "
|
|
371
|
+
f"{resolved_ref!r}. Call check_canvas_extension_update() first."
|
|
372
|
+
)
|
|
373
|
+
manifest = _load_validated_manifest(staged_path, name)
|
|
374
|
+
|
|
375
|
+
install_path = installed_dir / name
|
|
376
|
+
backup_path = _staging_root(installed_dir) / f"{name}.previous"
|
|
377
|
+
shutil.rmtree(backup_path, ignore_errors=True)
|
|
378
|
+
|
|
379
|
+
# POSIX rename() can't atomically replace a non-empty directory, so the
|
|
380
|
+
# active bundle moves aside first; roll back if the second rename fails.
|
|
381
|
+
os.replace(install_path, backup_path)
|
|
382
|
+
try:
|
|
383
|
+
os.replace(staged_path, install_path)
|
|
384
|
+
except OSError:
|
|
385
|
+
os.replace(backup_path, install_path)
|
|
386
|
+
raise
|
|
387
|
+
shutil.rmtree(backup_path, ignore_errors=True)
|
|
388
|
+
shutil.rmtree(_staging_root(installed_dir) / name, ignore_errors=True)
|
|
389
|
+
|
|
390
|
+
info = InstallationInfo.from_extension(
|
|
391
|
+
manifest,
|
|
392
|
+
source=current_info.source,
|
|
393
|
+
install_path=install_path,
|
|
394
|
+
requested_ref=current_info.requested_ref,
|
|
395
|
+
resolved_ref=resolved_ref,
|
|
396
|
+
repo_path=current_info.repo_path,
|
|
397
|
+
)
|
|
398
|
+
info.enabled = enabled
|
|
399
|
+
|
|
400
|
+
with manager.metadata_session as session:
|
|
401
|
+
session.extensions[name] = info
|
|
402
|
+
|
|
403
|
+
return info
|