openhands-agent-server 1.40.1__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.1 → openhands_agent_server-1.41.0}/PKG-INFO +1 -1
  2. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/__init__.py +44 -0
  3. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/installed.py +403 -0
  4. openhands_agent_server-1.41.0/openhands/agent_server/canvas_extensions/manifest.py +166 -0
  5. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_router.py +0 -35
  6. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/models.py +1 -33
  7. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  8. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/SOURCES.txt +6 -0
  9. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/pyproject.toml +1 -1
  10. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/__init__.py +0 -0
  11. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/__main__.py +0 -0
  12. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/_secret_redaction.py +0 -0
  13. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  14. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/agent_profiles_router.py +0 -0
  15. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/api.py +0 -0
  16. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/auth_router.py +0 -0
  17. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_router.py +0 -0
  18. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/bash_service.py +0 -0
  19. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/config.py +0 -0
  20. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_lease.py +0 -0
  21. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_router.py +0 -0
  22. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/conversation_service.py +0 -0
  23. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/credential_binding.py +0 -0
  24. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/dependencies.py +0 -0
  25. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_router.py +0 -0
  26. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/desktop_service.py +0 -0
  27. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/Dockerfile +0 -0
  28. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/build.py +0 -0
  29. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  30. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/env_parser.py +0 -0
  31. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/event_router.py +0 -0
  32. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/event_service.py +0 -0
  33. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/file_router.py +0 -0
  34. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/git_router.py +0 -0
  35. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_router.py +0 -0
  36. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/hooks_service.py +0 -0
  37. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/init_router.py +0 -0
  38. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/llm_router.py +0 -0
  39. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/logging_config.py +0 -0
  40. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/mcp_oauth_store.py +0 -0
  41. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/middleware.py +0 -0
  42. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/__init__.py +0 -0
  43. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/models.py +0 -0
  44. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/router.py +0 -0
  45. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/openai/service.py +0 -0
  46. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/openapi.py +0 -0
  47. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/__init__.py +0 -0
  48. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/models.py +0 -0
  49. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/persistence/store.py +0 -0
  50. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_router.py +0 -0
  51. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/plugins_service.py +0 -0
  52. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/profiles_router.py +0 -0
  53. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/pub_sub.py +0 -0
  54. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/py.typed +0 -0
  55. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/server_details_router.py +0 -0
  56. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/settings_router.py +0 -0
  57. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_router.py +0 -0
  58. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/skills_service.py +0 -0
  59. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/sockets.py +0 -0
  60. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/sub_agents_router.py +0 -0
  61. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/__init__.py +0 -0
  62. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/factory.py +0 -0
  63. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  64. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/models.py +0 -0
  65. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/policy.py +0 -0
  66. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  67. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  68. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/service.py +0 -0
  69. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/sink.py +0 -0
  70. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
  71. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_preload_service.py +0 -0
  72. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/tool_router.py +0 -0
  73. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/utils.py +0 -0
  74. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  75. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  76. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_router.py +0 -0
  77. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/vscode_service.py +0 -0
  78. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/workspace_router.py +0 -0
  79. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands/agent_server/workspaces_router.py +0 -0
  80. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  81. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  82. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  83. {openhands_agent_server-1.40.1 → openhands_agent_server-1.41.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  84. {openhands_agent_server-1.40.1 → 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.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
@@ -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
@@ -0,0 +1,166 @@
1
+ """Canvas Extensions manifest: schema, validation, and entrypoint containment.
2
+
3
+ A Canvas extension is an installable UI bundle that contributes pages to the
4
+ OpenHands Canvas frontend. Extensions are installed and served entirely by
5
+ the agent-server (via ``openhands.sdk.extensions.installation``, the same
6
+ type-agnostic install-tracking framework Plugins/Skills use); nothing here
7
+ is consumed by ``Agent``/``Conversation``.
8
+
9
+ This module defines the manifest schema (``canvas-extension.json``) and the
10
+ two security-critical checks around it:
11
+
12
+ * Name / contribution-id / page-path validation (syntactic, on the model).
13
+ * Entrypoint containment (filesystem-level, once a package root is known) —
14
+ rejects both textual path traversal and symlink escapes.
15
+ """
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Final
20
+
21
+ from pydantic import BaseModel, Field, field_validator
22
+
23
+ from openhands.sdk.extensions.installation.utils import validate_extension_name
24
+
25
+
26
+ # Filename a canvas extension's manifest is loaded from, at its package root.
27
+ MANIFEST_FILENAME: Final[str] = "canvas-extension.json"
28
+
29
+ # Absolute, kebab-case, multi-segment UI route, e.g. "/dashboard/settings".
30
+ _PAGE_PATH_PATTERN: re.Pattern[str] = re.compile(
31
+ r"^/[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*$"
32
+ )
33
+
34
+
35
+ class CanvasExtensionPage(BaseModel):
36
+ """A single page contributed to the Canvas UI by an extension."""
37
+
38
+ id: str = Field(description="Unique contribution id within the extension")
39
+ title: str = Field(description="Page title shown in Canvas navigation")
40
+ path: str = Field(description="Route the page is mounted at, e.g. '/dashboard'")
41
+
42
+ @field_validator("id")
43
+ @classmethod
44
+ def _validate_id(cls, v: str) -> str:
45
+ try:
46
+ validate_extension_name(v)
47
+ except ValueError as e:
48
+ raise ValueError(
49
+ f"Invalid contribution id. Expected kebab-case, got {v!r}."
50
+ ) from e
51
+ return v
52
+
53
+ @field_validator("path")
54
+ @classmethod
55
+ def _validate_path(cls, v: str) -> str:
56
+ if not _PAGE_PATH_PATTERN.fullmatch(v):
57
+ raise ValueError(
58
+ "Invalid page path. Expected an absolute kebab-case route "
59
+ f"(e.g. '/dashboard'), got {v!r}."
60
+ )
61
+ return v
62
+
63
+
64
+ class CanvasExtensionContributes(BaseModel):
65
+ """Contributions an extension makes to the Canvas UI."""
66
+
67
+ pages: list[CanvasExtensionPage] = Field(
68
+ default_factory=list, description="Pages contributed to Canvas navigation"
69
+ )
70
+
71
+ @field_validator("pages")
72
+ @classmethod
73
+ def _validate_unique_pages(
74
+ cls, v: list[CanvasExtensionPage]
75
+ ) -> list[CanvasExtensionPage]:
76
+ seen_ids: set[str] = set()
77
+ seen_paths: set[str] = set()
78
+ for page in v:
79
+ if page.id in seen_ids:
80
+ raise ValueError(f"Duplicate page contribution id: {page.id!r}")
81
+ if page.path in seen_paths:
82
+ raise ValueError(f"Duplicate page path: {page.path!r}")
83
+ seen_ids.add(page.id)
84
+ seen_paths.add(page.path)
85
+ return v
86
+
87
+
88
+ class CanvasExtensionManifest(BaseModel):
89
+ """Canvas extension manifest (``canvas-extension.json``)."""
90
+
91
+ schema_version: int = Field(description="Manifest schema version")
92
+ name: str = Field(description="Extension name (kebab-case)")
93
+ display_name: str = Field(description="Human-readable extension name")
94
+ version: str = Field(description="Extension version")
95
+ description: str = Field(default="", description="Extension description")
96
+ entrypoint: str = Field(
97
+ description=(
98
+ "Path, relative to the extension package root, to the bundle entry file"
99
+ )
100
+ )
101
+ contributes: CanvasExtensionContributes = Field(
102
+ default_factory=CanvasExtensionContributes,
103
+ description="Contributions this extension makes to the Canvas UI",
104
+ )
105
+
106
+ @field_validator("name")
107
+ @classmethod
108
+ def _validate_name(cls, v: str) -> str:
109
+ validate_extension_name(v)
110
+ return v
111
+
112
+ @field_validator("entrypoint")
113
+ @classmethod
114
+ def _validate_entrypoint(cls, v: str) -> str:
115
+ """Reject textual traversal/absolute paths.
116
+
117
+ Syntactic only — see :func:`resolve_entrypoint` for the real,
118
+ symlink-aware containment check against the installed package root.
119
+ """
120
+ if not v:
121
+ raise ValueError("entrypoint must not be empty")
122
+ if v.startswith("/"):
123
+ raise ValueError("entrypoint must be relative, not absolute")
124
+ if ".." in Path(v).parts:
125
+ raise ValueError(
126
+ "entrypoint cannot contain '..' (parent directory traversal)"
127
+ )
128
+ return v
129
+
130
+
131
+ def resolve_entrypoint(manifest: CanvasExtensionManifest, package_root: Path) -> Path:
132
+ """Resolve ``manifest.entrypoint`` against ``package_root``, safely.
133
+
134
+ Field-level validation on ``entrypoint`` only rejects textual traversal
135
+ (``..``) and absolute paths. It cannot catch a symlink inside the
136
+ package that resolves outside of it. This performs the real
137
+ filesystem-level containment check (resolving symlinks) and must be
138
+ called both when an extension is installed and again immediately
139
+ before its entrypoint is read or served over HTTP.
140
+
141
+ Args:
142
+ manifest: A validated manifest.
143
+ package_root: The extension's installed package root directory.
144
+
145
+ Returns:
146
+ The resolved, contained entrypoint path.
147
+
148
+ Raises:
149
+ ValueError: If the resolved entrypoint escapes ``package_root``, or
150
+ does not resolve to a regular file within it (covers ``.``,
151
+ a directory, a dangling symlink, and symlink cycles — none of
152
+ which ``is_relative_to`` alone rejects).
153
+ """
154
+ root = package_root.resolve()
155
+ candidate = (root / manifest.entrypoint).resolve()
156
+ if not candidate.is_relative_to(root):
157
+ raise ValueError(
158
+ f"entrypoint {manifest.entrypoint!r} resolves outside the "
159
+ "extension package root"
160
+ )
161
+ if not candidate.is_file():
162
+ raise ValueError(
163
+ f"entrypoint {manifest.entrypoint!r} does not resolve to a file "
164
+ "in the extension package"
165
+ )
166
+ return candidate
@@ -53,7 +53,6 @@ from openhands.sdk.mcp.config import (
53
53
  )
54
54
  from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
55
55
  from openhands.sdk.utils.cipher import Cipher
56
- from openhands.sdk.utils.deprecation import warn_deprecated
57
56
 
58
57
 
59
58
  logger = get_logger(__name__)
@@ -100,44 +99,13 @@ class _RemoteMCPServerSpec(BaseModel):
100
99
  type: Literal["http", "shttp", "streamable-http", "sse"]
101
100
  url: str = Field(..., min_length=1)
102
101
  headers: dict[str, str] = Field(default_factory=dict)
103
- api_key: str | None = Field(
104
- default=None,
105
- deprecated=True,
106
- description=(
107
- "Deprecated bearer token. Prefer auth.strategy='bearer'. If provided "
108
- "without auth, sent as 'Authorization: Bearer <token>'."
109
- ),
110
- )
111
102
  auth: MCPAuthCredential | None = None
112
103
  timeout: float | None = None
113
104
  sse_read_timeout: float | None = None
114
105
  keep_alive: bool | None = None
115
106
 
116
- @model_validator(mode="before")
117
- @classmethod
118
- def _warn_legacy_api_key(cls, value: object) -> object:
119
- if isinstance(value, dict) and value.get("api_key") is not None:
120
- warn_deprecated(
121
- "_RemoteMCPServerSpec.api_key",
122
- deprecated_in="1.36.0",
123
- removed_in="1.41.0",
124
- details="Use auth.strategy='bearer' with auth.value instead.",
125
- stacklevel=3,
126
- )
127
- return value
128
-
129
107
  @model_validator(mode="after")
130
108
  def _reject_ambiguous_auth(self) -> _RemoteMCPServerSpec:
131
- api_key = self.__dict__.get("api_key")
132
- if api_key is not None and self.auth is not None:
133
- raise ValueError("api_key cannot be combined with auth.")
134
- if api_key is not None and any(
135
- name.lower() == "authorization" for name in self.headers
136
- ):
137
- raise ValueError(
138
- "api_key cannot be combined with an explicit top-level "
139
- "'Authorization' header; use auth.strategy='header' instead."
140
- )
141
109
  if self.auth is not None and any(
142
110
  name.lower() == "authorization" for name in self.headers
143
111
  ):
@@ -149,7 +117,6 @@ class _RemoteMCPServerSpec(BaseModel):
149
117
 
150
118
  def to_mcp_server(self) -> MCPServer:
151
119
  transport = "http" if self.type == "shttp" else self.type
152
- api_key = self.__dict__.get("api_key")
153
120
  data: dict[str, Any] = {
154
121
  "url": self.url,
155
122
  "transport": transport,
@@ -160,8 +127,6 @@ class _RemoteMCPServerSpec(BaseModel):
160
127
  }
161
128
  if self.auth is not None:
162
129
  data["auth"] = self.auth
163
- elif api_key is not None:
164
- data["auth"] = {"strategy": "bearer", "value": api_key}
165
130
  return MCPServer.model_validate(data)
166
131
 
167
132
 
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from abc import ABC
4
4
  from datetime import datetime
5
5
  from enum import Enum, StrEnum
6
- from typing import TYPE_CHECKING, Any, TypeAlias
6
+ from typing import Any
7
7
  from uuid import UUID, uuid4
8
8
 
9
9
  from pydantic import BaseModel, Field, field_validator
@@ -14,7 +14,6 @@ from openhands.sdk.conversation.conversation_stats import ConversationStats
14
14
  from openhands.sdk.conversation.request import ( # re-export for backward compat
15
15
  ACPEnabledAgent as ACPEnabledAgent,
16
16
  SendMessageRequest as SendMessageRequest,
17
- StartACPConversationRequest as StartACPConversationRequest,
18
17
  StartConversationRequest as StartConversationRequest,
19
18
  )
20
19
  from openhands.sdk.conversation.secret_registry import SecretRegistry
@@ -38,7 +37,6 @@ from openhands.sdk.security.confirmation_policy import (
38
37
  )
39
38
  from openhands.sdk.tool.client_tool import ClientToolSpec
40
39
  from openhands.sdk.utils import OpenHandsUUID, utc_now
41
- from openhands.sdk.utils.deprecation import warn_deprecated
42
40
  from openhands.sdk.utils.models import (
43
41
  DiscriminatedUnionMixin,
44
42
  OpenHandsModel,
@@ -392,36 +390,6 @@ def trim_conversation_response_skills(info: ConversationInfo) -> ConversationInf
392
390
  return info.model_copy(update={"agent": trimmed_agent})
393
391
 
394
392
 
395
- # Deprecated compatibility aliases for the old ACP-specific response names.
396
- # Keep runtime assignment aliases so existing imports still resolve to the
397
- # canonical Pydantic models; PEP 695 ``type`` aliases would not preserve that.
398
- if TYPE_CHECKING:
399
- ACPConversationInfo: TypeAlias = ConversationInfo # noqa: UP040
400
- ACPConversationPage: TypeAlias = ConversationPage # noqa: UP040
401
-
402
-
403
- _DEPRECATED_ACP_RESPONSE_ALIASES: dict[str, type[BaseModel]] = {
404
- "ACPConversationInfo": ConversationInfo,
405
- "ACPConversationPage": ConversationPage,
406
- }
407
-
408
-
409
- def __getattr__(name: str) -> Any:
410
- if name in _DEPRECATED_ACP_RESPONSE_ALIASES:
411
- warn_deprecated(
412
- f"openhands.agent_server.models.{name}",
413
- deprecated_in="1.36.0",
414
- removed_in="1.41.0",
415
- details=(
416
- "The ACP-specific response model names are compatibility aliases. "
417
- "Use ConversationInfo or ConversationPage instead."
418
- ),
419
- stacklevel=2,
420
- )
421
- return _DEPRECATED_ACP_RESPONSE_ALIASES[name]
422
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
423
-
424
-
425
393
  class ConfirmationResponseRequest(BaseModel):
426
394
  """Payload to accept or reject a pending action."""
427
395
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.40.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
@@ -49,6 +49,9 @@ pyproject.toml
49
49
  ./openhands/agent_server/vscode_service.py
50
50
  ./openhands/agent_server/workspace_router.py
51
51
  ./openhands/agent_server/workspaces_router.py
52
+ ./openhands/agent_server/canvas_extensions/__init__.py
53
+ ./openhands/agent_server/canvas_extensions/installed.py
54
+ ./openhands/agent_server/canvas_extensions/manifest.py
52
55
  ./openhands/agent_server/docker/Dockerfile
53
56
  ./openhands/agent_server/docker/build.py
54
57
  ./openhands/agent_server/docker/wallpaper.svg
@@ -121,6 +124,9 @@ openhands/agent_server/vscode_router.py
121
124
  openhands/agent_server/vscode_service.py
122
125
  openhands/agent_server/workspace_router.py
123
126
  openhands/agent_server/workspaces_router.py
127
+ openhands/agent_server/canvas_extensions/__init__.py
128
+ openhands/agent_server/canvas_extensions/installed.py
129
+ openhands/agent_server/canvas_extensions/manifest.py
124
130
  openhands/agent_server/docker/Dockerfile
125
131
  openhands/agent_server/docker/build.py
126
132
  openhands/agent_server/docker/wallpaper.svg
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.40.1"
3
+ version = "1.41.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"