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.
Files changed (84) hide show
  1. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/_secrets_exposure.py +19 -0
  3. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/agent_profiles_router.py +16 -33
  4. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/__init__.py +44 -0
  5. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/installed.py +403 -0
  6. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/manifest.py +166 -0
  7. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/config.py +17 -2
  8. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_service.py +147 -34
  9. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_oauth_store.py +4 -1
  10. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_router.py +0 -35
  11. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/models.py +1 -33
  12. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/profiles_router.py +7 -25
  13. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/settings_router.py +43 -0
  14. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  15. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/SOURCES.txt +6 -0
  16. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/pyproject.toml +1 -1
  17. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/__init__.py +0 -0
  18. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/__main__.py +0 -0
  19. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/_secret_redaction.py +0 -0
  20. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/api.py +0 -0
  21. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/auth_router.py +0 -0
  22. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_router.py +0 -0
  23. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_lease.py +0 -0
  25. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_router.py +0 -0
  26. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/credential_binding.py +0 -0
  27. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/dependencies.py +0 -0
  28. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_router.py +0 -0
  29. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_service.py +0 -0
  30. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/Dockerfile +0 -0
  31. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/build.py +0 -0
  32. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  33. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/env_parser.py +0 -0
  34. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/event_router.py +0 -0
  35. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/event_service.py +0 -0
  36. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/file_router.py +0 -0
  37. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/git_router.py +0 -0
  38. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_router.py +0 -0
  39. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_service.py +0 -0
  40. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/init_router.py +0 -0
  41. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/llm_router.py +0 -0
  42. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/logging_config.py +0 -0
  43. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/middleware.py +0 -0
  44. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/__init__.py +0 -0
  45. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/models.py +0 -0
  46. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/router.py +0 -0
  47. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/service.py +0 -0
  48. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/openapi.py +0 -0
  49. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/__init__.py +0 -0
  50. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/models.py +0 -0
  51. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/store.py +0 -0
  52. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_router.py +0 -0
  53. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_service.py +0 -0
  54. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/pub_sub.py +0 -0
  55. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/py.typed +0 -0
  56. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/server_details_router.py +0 -0
  57. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_router.py +0 -0
  58. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_service.py +0 -0
  59. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/sockets.py +0 -0
  60. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/sub_agents_router.py +0 -0
  61. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/__init__.py +0 -0
  62. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/factory.py +0 -0
  63. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  64. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/models.py +0 -0
  65. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/policy.py +0 -0
  66. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  67. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  68. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/service.py +0 -0
  69. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sink.py +0 -0
  70. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
  71. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_preload_service.py +0 -0
  72. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_router.py +0 -0
  73. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/utils.py +0 -0
  74. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  75. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  76. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_router.py +0 -0
  77. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_service.py +0 -0
  78. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/workspace_router.py +0 -0
  79. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands/agent_server/workspaces_router.py +0 -0
  80. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  81. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  82. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  83. {openhands_agent_server-1.40.0 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  84. {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.40.0
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 get_cipher, get_config
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 _store_errors():
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 _store_errors(), store.lock():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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 _store_errors():
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