openhands-agent-server 1.43.0__tar.gz → 1.44.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 (87) hide show
  1. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/api.py +2 -0
  3. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/__init__.py +4 -0
  4. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/installed.py +41 -0
  5. openhands_agent_server-1.44.0/openhands/agent_server/canvas_extensions_router.py +291 -0
  6. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_service.py +73 -13
  7. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/Dockerfile +75 -53
  8. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/event_service.py +7 -0
  9. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/models.py +48 -0
  10. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/store.py +2 -2
  11. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  12. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/SOURCES.txt +2 -0
  13. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/pyproject.toml +1 -1
  14. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__init__.py +0 -0
  15. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__main__.py +0 -0
  16. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/_secret_redaction.py +0 -0
  17. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  18. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/agent_profiles_router.py +0 -0
  19. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/auth_router.py +0 -0
  20. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/bash_router.py +0 -0
  21. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/bash_service.py +0 -0
  22. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
  23. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/config.py +0 -0
  24. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_lease.py +0 -0
  25. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_router.py +0 -0
  26. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/credential_binding.py +0 -0
  27. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/dependencies.py +0 -0
  28. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/desktop_router.py +0 -0
  29. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/desktop_service.py +0 -0
  30. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/build.py +0 -0
  31. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  32. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/env_parser.py +0 -0
  33. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/event_router.py +0 -0
  34. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/file_router.py +0 -0
  35. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/git_router.py +0 -0
  36. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/hooks_router.py +0 -0
  37. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/hooks_service.py +0 -0
  38. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/init_router.py +0 -0
  39. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/llm_router.py +0 -0
  40. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/logging_config.py +0 -0
  41. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/mcp_oauth_store.py +0 -0
  42. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/mcp_router.py +0 -0
  43. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/middleware.py +0 -0
  44. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/models.py +0 -0
  45. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/__init__.py +0 -0
  46. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/models.py +0 -0
  47. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/router.py +0 -0
  48. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/service.py +0 -0
  49. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openapi.py +0 -0
  50. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/__init__.py +0 -0
  51. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/plugins_router.py +0 -0
  52. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/plugins_service.py +0 -0
  53. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/profiles_router.py +0 -0
  54. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/provider_connections_router.py +0 -0
  55. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/pub_sub.py +0 -0
  56. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/py.typed +0 -0
  57. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/server_details_router.py +0 -0
  58. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/settings_router.py +0 -0
  59. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/skills_router.py +0 -0
  60. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/skills_service.py +0 -0
  61. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/sockets.py +0 -0
  62. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/sub_agents_router.py +0 -0
  63. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/__init__.py +0 -0
  64. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/factory.py +0 -0
  65. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  66. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/models.py +0 -0
  67. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/policy.py +0 -0
  68. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  69. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  70. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/service.py +0 -0
  71. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/sink.py +0 -0
  72. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
  73. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry_types.py +0 -0
  74. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/tool_preload_service.py +0 -0
  75. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/tool_router.py +0 -0
  76. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/utils.py +0 -0
  77. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  78. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  79. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_router.py +0 -0
  80. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_service.py +0 -0
  81. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/workspace_router.py +0 -0
  82. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/workspaces_router.py +0 -0
  83. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  84. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  85. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  86. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  87. {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.43.0
3
+ Version: 1.44.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
@@ -21,6 +21,7 @@ from openhands.agent_server.agent_profiles_router import agent_profiles_router
21
21
  from openhands.agent_server.auth_router import auth_router
22
22
  from openhands.agent_server.bash_router import bash_router
23
23
  from openhands.agent_server.bash_service import get_default_bash_event_service
24
+ from openhands.agent_server.canvas_extensions_router import canvas_extensions_router
24
25
  from openhands.agent_server.config import (
25
26
  Config,
26
27
  get_default_config,
@@ -441,6 +442,7 @@ def _add_api_routes(app: FastAPI) -> None:
441
442
  api_router.include_router(skills_router)
442
443
  api_router.include_router(sub_agents_router)
443
444
  api_router.include_router(plugins_router)
445
+ api_router.include_router(canvas_extensions_router)
444
446
  api_router.include_router(hooks_router)
445
447
  api_router.include_router(llm_router)
446
448
  api_router.include_router(provider_connections_router)
@@ -7,7 +7,9 @@ from openhands.agent_server.canvas_extensions.installed import (
7
7
  check_canvas_extension_update,
8
8
  disable_canvas_extension,
9
9
  enable_canvas_extension,
10
+ get_canvas_extension_bundle_path,
10
11
  get_installed_canvas_extension,
12
+ get_installed_canvas_extension_manifest,
11
13
  get_installed_canvas_extensions_dir,
12
14
  install_canvas_extension,
13
15
  list_installed_canvas_extensions,
@@ -37,7 +39,9 @@ __all__ = [
37
39
  "list_installed_canvas_extensions",
38
40
  "load_installed_canvas_extensions",
39
41
  "get_installed_canvas_extension",
42
+ "get_installed_canvas_extension_manifest",
40
43
  "get_installed_canvas_extensions_dir",
44
+ "get_canvas_extension_bundle_path",
41
45
  "CanvasExtensionUpdateCheck",
42
46
  "check_canvas_extension_update",
43
47
  "apply_canvas_extension_update",
@@ -206,6 +206,47 @@ def get_installed_canvas_extension(
206
206
  return _manager(_resolve_installed_dir(installed_dir)).get(name)
207
207
 
208
208
 
209
+ def get_installed_canvas_extension_manifest(
210
+ name: str, installed_dir: Path | None = None
211
+ ) -> CanvasExtensionManifest | None:
212
+ """Load the validated manifest for an installed extension.
213
+
214
+ Re-reads and re-validates the stored manifest against the live install
215
+ path -- the copy validated at install time may have changed on disk
216
+ since.
217
+
218
+ Returns:
219
+ None if not installed, or if the install no longer validates.
220
+ """
221
+ installed_dir = _resolve_installed_dir(installed_dir)
222
+ if get_installed_canvas_extension(name, installed_dir) is None:
223
+ return None
224
+ try:
225
+ return CanvasExtensionInstallationInterface.load_from_dir(installed_dir / name)
226
+ except (ValidationError, ValueError, OSError):
227
+ return None
228
+
229
+
230
+ def get_canvas_extension_bundle_path(
231
+ name: str, installed_dir: Path | None = None
232
+ ) -> Path | None:
233
+ """Resolve the on-disk path to *name*'s entrypoint bundle file.
234
+
235
+ Re-validates the manifest and entrypoint containment against the live
236
+ install path on every call -- this backs a bundle serve, so the check
237
+ done at install time isn't enough on its own (a symlink could change
238
+ between requests).
239
+
240
+ Returns:
241
+ None if not installed, or if the install no longer validates.
242
+ """
243
+ installed_dir = _resolve_installed_dir(installed_dir)
244
+ manifest = get_installed_canvas_extension_manifest(name, installed_dir)
245
+ if manifest is None:
246
+ return None
247
+ return resolve_entrypoint(manifest, installed_dir / name)
248
+
249
+
209
250
  class CanvasExtensionUpdateCheck(BaseModel):
210
251
  """Result of ``check_canvas_extension_update``.
211
252
 
@@ -0,0 +1,291 @@
1
+ """Canvas Extensions router for OpenHands Agent Server.
2
+
3
+ HTTP API endpoints for canvas extension operations. Business logic is
4
+ delegated to ``canvas_extensions/installed.py``; this module mirrors
5
+ ``plugins_router.py`` / ``skills_router.py`` and stays focused on HTTP
6
+ concerns: install / list / get / enable-disable / uninstall, plus serving
7
+ an installed extension's entrypoint bundle to the Canvas frontend.
8
+ """
9
+
10
+ from typing import Annotated, Final
11
+
12
+ from fastapi import APIRouter, HTTPException, Path
13
+ from fastapi.responses import FileResponse
14
+ from pydantic import BaseModel, Field, ValidationError
15
+
16
+ from openhands.agent_server.canvas_extensions.installed import (
17
+ InstalledCanvasExtensionInfo,
18
+ disable_canvas_extension,
19
+ enable_canvas_extension,
20
+ get_canvas_extension_bundle_path,
21
+ get_installed_canvas_extension,
22
+ get_installed_canvas_extension_manifest,
23
+ install_canvas_extension,
24
+ list_installed_canvas_extensions,
25
+ uninstall_canvas_extension,
26
+ )
27
+ from openhands.agent_server.canvas_extensions.manifest import CanvasExtensionManifest
28
+ from openhands.sdk.extensions.fetch import ExtensionFetchError
29
+
30
+
31
+ canvas_extensions_router = APIRouter(
32
+ prefix="/canvas-extensions", tags=["Canvas Extensions"]
33
+ )
34
+
35
+ # Matches the SDK's validate_extension_name() rule.
36
+ CANVAS_EXTENSION_NAME_PATTERN: Final[str] = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
37
+
38
+ CanvasExtensionNamePath = Annotated[
39
+ str,
40
+ Path(
41
+ min_length=1,
42
+ max_length=255,
43
+ pattern=CANVAS_EXTENSION_NAME_PATTERN,
44
+ description="Canvas extension name (lowercase alphanumeric, hyphens)",
45
+ ),
46
+ ]
47
+
48
+
49
+ class InstallCanvasExtensionRequest(BaseModel):
50
+ """Request body for installing a canvas extension."""
51
+
52
+ source: str = Field(
53
+ min_length=1,
54
+ description=(
55
+ "Canvas extension source - git URL, GitHub shorthand, or local "
56
+ "path. Examples: 'github:owner/repo', '/path/to/extension'"
57
+ ),
58
+ )
59
+ ref: str | None = Field(
60
+ default=None, description="Optional branch, tag, or commit to install"
61
+ )
62
+ repo_path: str | None = Field(
63
+ default=None,
64
+ description="Subdirectory path within the repository (for monorepos)",
65
+ )
66
+ force: bool = Field(
67
+ default=False, description="If true, overwrite existing installation"
68
+ )
69
+
70
+
71
+ class InstalledCanvasExtensionResponse(BaseModel):
72
+ """Response containing installed canvas extension information."""
73
+
74
+ name: str = Field(description="Canvas extension name")
75
+ version: str = Field(default="", description="Canvas extension version")
76
+ description: str = Field(default="", description="Canvas extension description")
77
+ enabled: bool = Field(
78
+ default=False, description="Whether the canvas extension is enabled"
79
+ )
80
+ source: str = Field(description="Original source (e.g., 'github:owner/repo')")
81
+ resolved_ref: str | None = Field(
82
+ default=None, description="Resolved git commit SHA"
83
+ )
84
+ repo_path: str | None = Field(
85
+ default=None, description="Subdirectory path within the repository"
86
+ )
87
+ installed_at: str = Field(description="ISO 8601 timestamp of installation")
88
+ install_path: str = Field(description="Path where the extension is installed")
89
+ manifest: CanvasExtensionManifest | None = Field(
90
+ default=None,
91
+ description=(
92
+ "The extension's validated manifest (display_name, "
93
+ "contributes.pages, ...). None if the installed manifest can "
94
+ "no longer be read."
95
+ ),
96
+ )
97
+
98
+ @classmethod
99
+ def from_info(
100
+ cls,
101
+ info: InstalledCanvasExtensionInfo,
102
+ manifest: CanvasExtensionManifest | None,
103
+ ) -> "InstalledCanvasExtensionResponse":
104
+ return cls(
105
+ name=info.name,
106
+ version=info.version,
107
+ description=info.description,
108
+ enabled=info.enabled,
109
+ source=info.source,
110
+ resolved_ref=info.resolved_ref,
111
+ repo_path=info.repo_path,
112
+ installed_at=info.installed_at,
113
+ install_path=str(info.install_path),
114
+ manifest=manifest,
115
+ )
116
+
117
+
118
+ class InstalledCanvasExtensionsListResponse(BaseModel):
119
+ """Response containing the list of installed canvas extensions."""
120
+
121
+ canvas_extensions: list[InstalledCanvasExtensionResponse]
122
+
123
+
124
+ class UpdateCanvasExtensionStateRequest(BaseModel):
125
+ """Request body for updating canvas extension state (enable/disable)."""
126
+
127
+ enabled: bool
128
+
129
+
130
+ class UpdateCanvasExtensionStateResponse(BaseModel):
131
+ """Response from a canvas extension state update."""
132
+
133
+ name: str
134
+ enabled: bool
135
+
136
+
137
+ class UninstallCanvasExtensionResponse(BaseModel):
138
+ """Response from a canvas extension uninstall."""
139
+
140
+ message: str
141
+
142
+
143
+ @canvas_extensions_router.post(
144
+ "/install",
145
+ response_model=InstalledCanvasExtensionResponse,
146
+ responses={
147
+ 400: {"description": "Failed to fetch canvas extension source"},
148
+ 409: {"description": "Canvas extension already installed (use force=true)"},
149
+ 422: {"description": "Invalid canvas extension (bad manifest, etc.)"},
150
+ },
151
+ )
152
+ def install_canvas_extension_endpoint(
153
+ request: InstallCanvasExtensionRequest,
154
+ ) -> InstalledCanvasExtensionResponse:
155
+ """Install a canvas extension from a git URL, GitHub shorthand, or local
156
+ path.
157
+
158
+ A fresh install always lands disabled, regardless of the request body --
159
+ there is no ``enabled`` field to smuggle a different state through.
160
+ """
161
+ try:
162
+ info = install_canvas_extension(
163
+ source=request.source,
164
+ ref=request.ref,
165
+ repo_path=request.repo_path,
166
+ force=request.force,
167
+ )
168
+ return InstalledCanvasExtensionResponse.from_info(
169
+ info, get_installed_canvas_extension_manifest(info.name)
170
+ )
171
+ except FileExistsError:
172
+ raise HTTPException(
173
+ status_code=409,
174
+ detail="Canvas extension already installed. Use force=true to overwrite.",
175
+ )
176
+ except ExtensionFetchError:
177
+ raise HTTPException(
178
+ status_code=400,
179
+ detail=(
180
+ "Failed to fetch canvas extension source. "
181
+ "Check that the source is valid."
182
+ ),
183
+ )
184
+ except (ValidationError, ValueError, OSError):
185
+ raise HTTPException(
186
+ status_code=422,
187
+ detail="Invalid canvas extension. Ensure the manifest is well-formed.",
188
+ )
189
+
190
+
191
+ @canvas_extensions_router.get(
192
+ "/installed", response_model=InstalledCanvasExtensionsListResponse
193
+ )
194
+ def list_installed_canvas_extensions_endpoint() -> (
195
+ InstalledCanvasExtensionsListResponse
196
+ ):
197
+ """List all installed canvas extensions (enabled and disabled)."""
198
+ infos = list_installed_canvas_extensions()
199
+ return InstalledCanvasExtensionsListResponse(
200
+ canvas_extensions=[
201
+ InstalledCanvasExtensionResponse.from_info(
202
+ i, get_installed_canvas_extension_manifest(i.name)
203
+ )
204
+ for i in infos
205
+ ]
206
+ )
207
+
208
+
209
+ @canvas_extensions_router.get(
210
+ "/installed/{extension_name}",
211
+ response_model=InstalledCanvasExtensionResponse,
212
+ responses={404: {"description": "Canvas extension not installed"}},
213
+ )
214
+ def get_installed_canvas_extension_endpoint(
215
+ extension_name: CanvasExtensionNamePath,
216
+ ) -> InstalledCanvasExtensionResponse:
217
+ """Get information about a specific installed canvas extension."""
218
+ info = get_installed_canvas_extension(name=extension_name)
219
+ if info is None:
220
+ raise HTTPException(
221
+ status_code=404,
222
+ detail=f"Canvas extension '{extension_name}' is not installed",
223
+ )
224
+ return InstalledCanvasExtensionResponse.from_info(
225
+ info, get_installed_canvas_extension_manifest(extension_name)
226
+ )
227
+
228
+
229
+ @canvas_extensions_router.patch(
230
+ "/installed/{extension_name}",
231
+ response_model=UpdateCanvasExtensionStateResponse,
232
+ responses={404: {"description": "Canvas extension not installed"}},
233
+ )
234
+ def set_canvas_extension_enabled_endpoint(
235
+ extension_name: CanvasExtensionNamePath, request: UpdateCanvasExtensionStateRequest
236
+ ) -> UpdateCanvasExtensionStateResponse:
237
+ """Enable or disable an installed canvas extension."""
238
+ fn = enable_canvas_extension if request.enabled else disable_canvas_extension
239
+ if not fn(name=extension_name):
240
+ raise HTTPException(
241
+ status_code=404,
242
+ detail=f"Canvas extension '{extension_name}' is not installed",
243
+ )
244
+ return UpdateCanvasExtensionStateResponse(
245
+ name=extension_name, enabled=request.enabled
246
+ )
247
+
248
+
249
+ @canvas_extensions_router.delete(
250
+ "/installed/{extension_name}",
251
+ response_model=UninstallCanvasExtensionResponse,
252
+ responses={404: {"description": "Canvas extension not installed"}},
253
+ )
254
+ def uninstall_canvas_extension_endpoint(
255
+ extension_name: CanvasExtensionNamePath,
256
+ ) -> UninstallCanvasExtensionResponse:
257
+ """Uninstall a canvas extension by name."""
258
+ if not uninstall_canvas_extension(name=extension_name):
259
+ raise HTTPException(
260
+ status_code=404,
261
+ detail=f"Canvas extension '{extension_name}' is not installed",
262
+ )
263
+ return UninstallCanvasExtensionResponse(
264
+ message=f"Canvas extension '{extension_name}' uninstalled"
265
+ )
266
+
267
+
268
+ @canvas_extensions_router.get(
269
+ "/installed/{extension_name}/bundle",
270
+ responses={404: {"description": "Canvas extension or bundle not found"}},
271
+ )
272
+ def get_canvas_extension_bundle_endpoint(
273
+ extension_name: CanvasExtensionNamePath,
274
+ ) -> FileResponse:
275
+ """Serve an installed canvas extension's entrypoint bundle file.
276
+
277
+ Entrypoint containment is re-validated against the live install path on
278
+ every request, not just at install time -- see
279
+ ``get_canvas_extension_bundle_path``. ``Cache-Control: no-cache`` forces
280
+ revalidation on each request, so a client never keeps serving a bundle
281
+ from before the extension's last refresh: the actual bytes are re-read
282
+ from disk, and Starlette's file-stat-based ``ETag``/``Last-Modified``
283
+ change the moment the on-disk revision does.
284
+ """
285
+ bundle_path = get_canvas_extension_bundle_path(name=extension_name)
286
+ if bundle_path is None:
287
+ raise HTTPException(
288
+ status_code=404,
289
+ detail=f"Canvas extension '{extension_name}' bundle not found",
290
+ )
291
+ return FileResponse(bundle_path, headers={"Cache-Control": "no-cache"})
@@ -5,11 +5,12 @@ import logging
5
5
  import os
6
6
  from collections.abc import Awaitable, Callable
7
7
  from concurrent.futures import ThreadPoolExecutor
8
- from contextlib import suppress
8
+ from contextlib import asynccontextmanager, suppress
9
9
  from dataclasses import dataclass, field
10
10
  from pathlib import Path
11
11
  from typing import TYPE_CHECKING, Any, cast
12
12
  from uuid import UUID, uuid4
13
+ from weakref import WeakValueDictionary
13
14
 
14
15
  import httpx
15
16
  from pydantic import BaseModel
@@ -645,6 +646,14 @@ class ConversationService:
645
646
  default_factory=dict, init=False
646
647
  )
647
648
  _lifecycle_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
649
+ _lifecycle_condition: asyncio.Condition = field(
650
+ default_factory=asyncio.Condition, init=False
651
+ )
652
+ _active_lifecycle_operations: int = field(default=0, init=False)
653
+ _exclusive_lifecycle_pending: bool = field(default=False, init=False)
654
+ _conversation_locks: WeakValueDictionary[UUID, asyncio.Lock] = field(
655
+ default_factory=WeakValueDictionary, init=False
656
+ )
648
657
  _conversation_webhook_subscribers: list["ConversationWebhookSubscriber"] = field(
649
658
  default_factory=list, init=False
650
659
  )
@@ -751,7 +760,7 @@ class ConversationService:
751
760
  secret_name: str,
752
761
  binding: VersionedCredentialBinding,
753
762
  ) -> None:
754
- async with self._lifecycle_lock:
763
+ async with self._conversation_lifecycle(conversation_id):
755
764
  event_services = self._event_services
756
765
  event_service = (
757
766
  event_services.get(conversation_id)
@@ -770,7 +779,7 @@ class ConversationService:
770
779
  )
771
780
 
772
781
  async def prepare_for_sandbox_pause(self) -> None:
773
- async with self._lifecycle_lock:
782
+ async with self._exclusive_lifecycle():
774
783
  event_services = self._event_services
775
784
  if event_services is None:
776
785
  raise ValueError("inactive_service")
@@ -1047,10 +1056,56 @@ class ConversationService:
1047
1056
  context=f"resuming conversation {stored.id}",
1048
1057
  )
1049
1058
 
1059
+ def _get_conversation_lock(self, conversation_id: UUID) -> asyncio.Lock:
1060
+ lock = self._conversation_locks.get(conversation_id)
1061
+ if lock is None:
1062
+ lock = asyncio.Lock()
1063
+ self._conversation_locks[conversation_id] = lock
1064
+ return lock
1065
+
1066
+ @asynccontextmanager
1067
+ async def _conversation_lifecycle(self, conversation_id: UUID):
1068
+ async with self._lifecycle_condition:
1069
+ await self._lifecycle_condition.wait_for(
1070
+ lambda: not self._exclusive_lifecycle_pending
1071
+ )
1072
+ self._active_lifecycle_operations += 1
1073
+ try:
1074
+ async with self._get_conversation_lock(conversation_id):
1075
+ yield
1076
+ finally:
1077
+ async with self._lifecycle_condition:
1078
+ self._active_lifecycle_operations -= 1
1079
+ if self._active_lifecycle_operations == 0:
1080
+ self._lifecycle_condition.notify_all()
1081
+
1082
+ @asynccontextmanager
1083
+ async def _exclusive_lifecycle(self):
1084
+ async with self._lifecycle_lock:
1085
+ try:
1086
+ async with self._lifecycle_condition:
1087
+ self._exclusive_lifecycle_pending = True
1088
+ await self._lifecycle_condition.wait_for(
1089
+ lambda: self._active_lifecycle_operations == 0
1090
+ )
1091
+ yield
1092
+ finally:
1093
+ async with self._lifecycle_condition:
1094
+ self._exclusive_lifecycle_pending = False
1095
+ self._lifecycle_condition.notify_all()
1096
+
1050
1097
  async def _get_or_load_event_service(
1051
1098
  self, conversation_id: UUID
1052
1099
  ) -> EventService | None:
1053
- async with self._lifecycle_lock:
1100
+ event_services = self._event_services
1101
+ if event_services is None:
1102
+ raise ValueError("inactive_service")
1103
+ if (
1104
+ conversation_id not in event_services
1105
+ and conversation_id not in self._conversation_records
1106
+ ):
1107
+ return None
1108
+ async with self._conversation_lifecycle(conversation_id):
1054
1109
  return await self._get_or_load_event_service_locked(conversation_id)
1055
1110
 
1056
1111
  async def _get_or_load_event_service_locked(
@@ -1318,7 +1373,7 @@ class ConversationService:
1318
1373
  if existing_record is not None or (
1319
1374
  existing_event_service is not None and existing_event_service.is_open()
1320
1375
  ):
1321
- async with self._lifecycle_lock:
1376
+ async with self._conversation_lifecycle(conversation_id):
1322
1377
  existing_event_service = self._event_services.get(conversation_id)
1323
1378
  if (
1324
1379
  existing_event_service is not None
@@ -1625,7 +1680,7 @@ class ConversationService:
1625
1680
  launched_agent_profile=launched_agent_profile,
1626
1681
  **request_data,
1627
1682
  )
1628
- async with self._lifecycle_lock:
1683
+ async with self._conversation_lifecycle(conversation_id):
1629
1684
  # New conversation: the agent is written to base_state.json (its
1630
1685
  # single source of truth), not to meta.json. Pass it explicitly.
1631
1686
  # ``new_agent`` is ``request.agent`` (decrypted when the request was
@@ -1683,10 +1738,15 @@ class ConversationService:
1683
1738
  return bool(await self._get_or_load_event_service(conversation_id))
1684
1739
 
1685
1740
  async def delete_conversation(self, conversation_id: UUID) -> bool:
1686
- async with self._lifecycle_lock:
1687
- event_services = self._event_services
1688
- if event_services is None:
1689
- raise ValueError("inactive_service")
1741
+ event_services = self._event_services
1742
+ if event_services is None:
1743
+ raise ValueError("inactive_service")
1744
+ if (
1745
+ conversation_id not in event_services
1746
+ and conversation_id not in self._conversation_records
1747
+ ):
1748
+ return False
1749
+ async with self._conversation_lifecycle(conversation_id):
1690
1750
  event_service = await self._get_or_load_event_service_locked(
1691
1751
  conversation_id,
1692
1752
  require_runtime_bindings=False,
@@ -1911,7 +1971,7 @@ class ConversationService:
1911
1971
  # directory so we don't leave stale state on disk.
1912
1972
  fork_dir = self.conversations_dir / fork_conv_id.hex
1913
1973
  try:
1914
- async with self._lifecycle_lock:
1974
+ async with self._conversation_lifecycle(fork_conv_id):
1915
1975
  fork_event_service = await self._start_event_service(
1916
1976
  fork_stored, is_new_conversation=True, agent=fork_agent
1917
1977
  )
@@ -2036,7 +2096,7 @@ class ConversationService:
2036
2096
 
2037
2097
  Running or externally-subscribed conversations are skipped.
2038
2098
  """
2039
- async with self._lifecycle_lock:
2099
+ async with self._exclusive_lifecycle():
2040
2100
  event_services = self._event_services
2041
2101
  if event_services is None:
2042
2102
  return
@@ -2092,7 +2152,7 @@ class ConversationService:
2092
2152
  await self._lease_renewal_task
2093
2153
  self._lease_renewal_task = None
2094
2154
 
2095
- async with self._lifecycle_lock:
2155
+ async with self._exclusive_lifecycle():
2096
2156
  event_services = self._event_services
2097
2157
  if event_services is None:
2098
2158
  return
@@ -78,6 +78,58 @@ RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
78
78
  # Fail fast if the expected binary is missing
79
79
  RUN test -x /agent-server/dist/openhands-agent-server
80
80
 
81
+ ####################################################################################
82
+ # ACP providers (parent-independent)
83
+ #
84
+ # Built from a fixed base rather than ${BASE_IMAGE} so that COPY --from=acp-providers
85
+ # below produces the exact same compressed layer for every base-image-minimal build,
86
+ # regardless of which BASE_IMAGE it lands on. This is what lets the ~600MB provider
87
+ # payload be shared across per-instance evaluation image builds instead of being
88
+ # reinstalled (and re-uploaded) once per instance.
89
+ #
90
+ # Failures here are fatal: there is no per-BASE_IMAGE fallback for a broken build of
91
+ # the shared payload itself (see the compatibility probe in base-image-minimal for
92
+ # the per-target-image fallback).
93
+ ####################################################################################
94
+ FROM python:3.13-bookworm AS acp-providers
95
+ # A fresh top-level path, not nested under a directory (e.g. /opt) that base
96
+ # images commonly already populate (SWE-bench images ship /opt/miniconda3):
97
+ # COPY --from=<stage> only produces a byte-identical layer across different
98
+ # BASE_IMAGE parents when the destination itself doesn't already exist in the
99
+ # parent, mirroring /agent-server's own top-level placement in the builder
100
+ # stage above.
101
+ ENV ACP_NODE_DIR=/acp-node
102
+ RUN set -eux; \
103
+ apt-get -o Acquire::Retries=5 update; \
104
+ apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
105
+ ca-certificates curl xz-utils; \
106
+ rm -rf /var/lib/apt/lists/*; \
107
+ ACP_WRAPPER_DIR=/opt/acp-wrappers; \
108
+ mkdir -p "$ACP_NODE_DIR" "$ACP_WRAPPER_DIR"; \
109
+ ARCH=$(uname -m); \
110
+ case "$ARCH" in \
111
+ x86_64|amd64) NARCH=x64; NODE_SHA256=69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec;; \
112
+ aarch64|arm64) NARCH=arm64; NODE_SHA256=08bfbf538bad0e8cbb0269f0173cca28d705874a67a22f60b57d99dc99e30050;; \
113
+ *) echo "Unsupported architecture for ACP providers: '$ARCH'" >&2; exit 1;; \
114
+ esac; \
115
+ NODE_TARBALL="/tmp/node-v22.14.0-linux-${NARCH}.tar.xz"; \
116
+ curl -fsSL --retry 5 --retry-delay 2 --retry-connrefused \
117
+ "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" -o "$NODE_TARBALL"; \
118
+ echo "$NODE_SHA256 $NODE_TARBALL" | sha256sum -c -; \
119
+ tar -xJ --strip-components=1 -C "$ACP_NODE_DIR" -f "$NODE_TARBALL"; \
120
+ rm -f "$NODE_TARBALL"; \
121
+ "$ACP_NODE_DIR/bin/node" --version; \
122
+ PATH="$ACP_NODE_DIR/bin:$PATH" "$ACP_NODE_DIR/bin/npm" install -g \
123
+ @agentclientprotocol/claude-agent-acp@0.63.0 \
124
+ @agentclientprotocol/codex-acp@1.1.7 \
125
+ @google/gemini-cli@0.46.0; \
126
+ for bin in claude-agent-acp codex-acp gemini; do \
127
+ printf '#!/bin/sh\nPATH="%s/bin:$PATH" exec "%s/bin/%s" "$@"\n' \
128
+ "$ACP_NODE_DIR" "$ACP_NODE_DIR" "$bin" \
129
+ > "$ACP_WRAPPER_DIR/$bin"; \
130
+ chmod +x "$ACP_WRAPPER_DIR/$bin"; \
131
+ done
132
+
81
133
  ####################################################################################
82
134
  # Base image (minimal)
83
135
  # It includes only basic packages and the UV runtime.
@@ -150,61 +202,31 @@ RUN set -eux; \
150
202
  mkdir -p /workspace/project; \
151
203
  chown -R "${USERNAME}:${USERNAME}" /workspace
152
204
 
153
- # Pre-install ACP servers for ACPAgent support (Claude Code, Codex, Gemini CLI)
154
- # Install Node.js 22 to a dedicated prefix so ACP packages get a modern runtime
205
+ # Install ACP servers for ACPAgent support (Claude Code, Codex, Gemini CLI).
206
+ # The payload itself (Node.js 22 + the ACP npm packages, under $ACP_NODE_DIR) is
207
+ # built once in the acp-providers stage above, independent of BASE_IMAGE, and
208
+ # copied in here so the layer is byte-identical across every base image it lands
209
+ # on. Only the compatibility probe below runs against the real BASE_IMAGE.
210
+ #
211
+ # Node.js 22 lives at a dedicated prefix so ACP packages get a modern runtime
155
212
  # WITHOUT overwriting the repo-specific Node.js that test suites depend on.
156
213
  # SWE-bench images ship NVM/apt-managed Node 8-14 which cannot run ACP packages.
157
- #
158
- # This step is best-effort: SWE-Bench Pro base images come from many distros
159
- # and some have an old glibc (or use musl) that cannot run the upstream Node
160
- # 22 glibc tarball. When that happens we leave $ACP_NODE_DIR empty and skip
161
- # ACP setup so the rest of the build (and non-ACP agents) still work.
162
- ENV ACP_NODE_DIR=/opt/acp-node
163
- RUN set -ux; \
164
- mkdir -p "$ACP_NODE_DIR"; \
165
- ARCH=$(uname -m); \
166
- NARCH=""; \
167
- NODE_SHA256=""; \
168
- case "$ARCH" in \
169
- x86_64|amd64) NARCH=x64; NODE_SHA256=69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec;; \
170
- aarch64|arm64) NARCH=arm64; NODE_SHA256=08bfbf538bad0e8cbb0269f0173cca28d705874a67a22f60b57d99dc99e30050;; \
171
- esac; \
172
- NODE_TARBALL=""; \
173
- if [ -z "$NARCH" ]; then \
174
- echo "Skipping ACP Node install: unsupported architecture '$ARCH'" >&2; \
175
- else \
176
- NODE_TARBALL="/tmp/node-v22.14.0-linux-${NARCH}.tar.xz"; \
177
- if curl -fsSL --retry 5 --retry-delay 2 --retry-connrefused "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" -o "$NODE_TARBALL" \
178
- && echo "$NODE_SHA256 $NODE_TARBALL" | sha256sum -c - \
179
- && tar -xJ --strip-components=1 -C "$ACP_NODE_DIR" -f "$NODE_TARBALL" \
180
- && "$ACP_NODE_DIR/bin/node" --version; then \
181
- PATH="$ACP_NODE_DIR/bin:$PATH"; \
182
- if "$ACP_NODE_DIR/bin/npm" install -g \
183
- @agentclientprotocol/claude-agent-acp@0.63.0 \
184
- @agentclientprotocol/codex-acp@1.1.7 \
185
- @google/gemini-cli@0.46.0; then \
186
- # Create wrappers in /usr/local/bin that prepend ACP's Node 22 to PATH.
187
- # This ensures the ACP binary's #!/usr/bin/env node shebang resolves
188
- # to Node 22, while the repo's own node (NVM/system) stays untouched
189
- # for tests.
190
- for bin in claude-agent-acp codex-acp gemini; do \
191
- if [ -e "$ACP_NODE_DIR/bin/$bin" ]; then \
192
- printf '#!/bin/sh\nPATH="%s/bin:$PATH" exec "%s/bin/%s" "$@"\n' \
193
- "$ACP_NODE_DIR" "$ACP_NODE_DIR" "$bin" \
194
- > /usr/local/bin/"$bin"; \
195
- chmod +x /usr/local/bin/"$bin"; \
196
- fi; \
197
- done; \
198
- else \
199
- echo "Warning: ACP npm install failed; ACP agents will not be available on this image" >&2; \
200
- rm -rf "$ACP_NODE_DIR"/*; \
201
- fi; \
202
- else \
203
- echo "Warning: ACP Node 22 runtime is not compatible with this base image (likely older glibc or musl libc); ACP agents will not be available" >&2; \
204
- rm -rf "$ACP_NODE_DIR"/*; \
205
- fi; \
206
- fi; \
207
- rm -f "$NODE_TARBALL" 2>/dev/null || true
214
+ ENV ACP_NODE_DIR=/acp-node
215
+ COPY --from=acp-providers /acp-node /acp-node
216
+ COPY --from=acp-providers /opt/acp-wrappers/. /usr/local/bin/
217
+
218
+ # This probe is best-effort: SWE-Bench Pro base images come from many distros
219
+ # and some have an old glibc (or use musl) that cannot run the upstream Node 22
220
+ # glibc build. The payload above was built and sha256-verified against a fixed,
221
+ # glibc-modern stage, so a failure here means "incompatible with this specific
222
+ # BASE_IMAGE", not "the ACP install failed". When that happens we remove the
223
+ # copied payload and wrappers so the rest of the build (and non-ACP agents)
224
+ # still work, rather than leaving binaries in place that cannot execute.
225
+ RUN if ! "$ACP_NODE_DIR/bin/node" --version >/dev/null 2>&1; then \
226
+ echo "Warning: ACP Node 22 runtime is not compatible with this base image (likely older glibc or musl libc); ACP agents will not be available" >&2; \
227
+ rm -rf "$ACP_NODE_DIR"/*; \
228
+ rm -f /usr/local/bin/claude-agent-acp /usr/local/bin/codex-acp /usr/local/bin/gemini; \
229
+ fi
208
230
 
209
231
  # Configure Claude Code managed settings for headless operation:
210
232
  # Allow all tool permissions (no human in the loop to approve).
@@ -1161,7 +1161,14 @@ class EventService:
1161
1161
  for e in state.events
1162
1162
  )
1163
1163
  if not already_observed:
1164
+ # The persisted HEAD can lag this action when the process
1165
+ # dies after writing the event file but before autosaving
1166
+ # leaf_event_id. Parent the recovery result to the action
1167
+ # explicitly; otherwise normal tree stamping attaches it to
1168
+ # the stale HEAD, making the action and result siblings and
1169
+ # leaving an orphan tool result on the active branch.
1164
1170
  error_event = AgentErrorEvent(
1171
+ parent_id=first_action.id,
1165
1172
  tool_name=first_action.tool_name,
1166
1173
  tool_call_id=first_action.tool_call_id,
1167
1174
  error=(
@@ -33,6 +33,38 @@ from openhands.sdk.settings import (
33
33
  from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret
34
34
 
35
35
 
36
+ class _SecretProbeCipher:
37
+ """Fake cipher that flags secret serialization without needing a real key.
38
+
39
+ Passed as ``context={"cipher": probe}`` this forces every secret field's
40
+ serializer down the "encrypted" branch (see ``resolve_expose_mode``),
41
+ reusing the real serialization pipeline to detect secret-bearing fields
42
+ instead of hand-walking the model for ``SecretStr`` instances. That
43
+ matters for fields like ``AgentContext.secrets``, whose bare-string
44
+ entries are plain ``str`` at rest and only become secret-shaped inside
45
+ their own field serializer at dump time -- a value-type walk can't see
46
+ that, but reusing the pipeline does.
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self.found = False
51
+
52
+ def try_decrypt_str(self, raw: str) -> str | None: # noqa: ARG002
53
+ return None
54
+
55
+ def encrypt(self, value: SecretStr) -> str:
56
+ if value.get_secret_value():
57
+ self.found = True
58
+ return ""
59
+
60
+
61
+ def _contains_secret_value(model: BaseModel) -> bool:
62
+ """Check if serializing `model` would touch any secret-bearing field."""
63
+ probe = _SecretProbeCipher()
64
+ model.model_dump(mode="json", context={"cipher": probe})
65
+ return probe.found
66
+
67
+
36
68
  class SettingsUpdatePayload(TypedDict, total=False):
37
69
  """Typed payload for PersistedSettings.update() method.
38
70
 
@@ -180,6 +212,17 @@ class PersistedSettings(BaseModel):
180
212
  )
181
213
  return bool(secret_value and secret_value.strip())
182
214
 
215
+ @property
216
+ def has_any_secret(self) -> bool:
217
+ """Check if these settings contain any secret value anywhere.
218
+
219
+ Broader than ``llm_api_key_is_set``: walks the whole ``agent_settings``
220
+ tree (MCP server env/headers, ``critic_api_key``, provider creds,
221
+ ``agent_context.secrets``, ...) rather than checking a fixed field
222
+ list, so it stays correct as new secret-bearing fields are added.
223
+ """
224
+ return _contains_secret_value(self.agent_settings)
225
+
183
226
  def update(
184
227
  self,
185
228
  payload: SettingsUpdatePayload,
@@ -412,6 +455,11 @@ class Secrets(BaseModel):
412
455
 
413
456
  model_config = ConfigDict(frozen=True)
414
457
 
458
+ @property
459
+ def has_any_secret(self) -> bool:
460
+ """Check if these secrets contain any non-empty value."""
461
+ return _contains_secret_value(self)
462
+
415
463
  def get_env_vars(self) -> dict[str, str]:
416
464
  """Get secrets as environment variables dict.
417
465
 
@@ -373,7 +373,7 @@ class FileSettingsStore(SettingsStore):
373
373
  else:
374
374
  context = {"expose_secrets": "plaintext"}
375
375
  # Warn about plaintext secret storage (only if secrets exist)
376
- if settings.llm_api_key_is_set:
376
+ if settings.has_any_secret:
377
377
  logger.warning(
378
378
  "Saving settings with secrets in PLAINTEXT (no cipher configured). "
379
379
  "Configure OH_SECRET_KEY for production deployments."
@@ -542,7 +542,7 @@ class FileSecretsStore(SecretsStore):
542
542
  else:
543
543
  context = {"expose_secrets": "plaintext"}
544
544
  # Warn about plaintext secret storage (only if secrets exist)
545
- if secrets.custom_secrets:
545
+ if secrets.has_any_secret:
546
546
  logger.warning(
547
547
  "Saving secrets in PLAINTEXT (no cipher configured). "
548
548
  "Configure OH_SECRET_KEY for production deployments."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.43.0
3
+ Version: 1.44.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
@@ -8,6 +8,7 @@ pyproject.toml
8
8
  ./openhands/agent_server/auth_router.py
9
9
  ./openhands/agent_server/bash_router.py
10
10
  ./openhands/agent_server/bash_service.py
11
+ ./openhands/agent_server/canvas_extensions_router.py
11
12
  ./openhands/agent_server/config.py
12
13
  ./openhands/agent_server/conversation_lease.py
13
14
  ./openhands/agent_server/conversation_router.py
@@ -85,6 +86,7 @@ openhands/agent_server/api.py
85
86
  openhands/agent_server/auth_router.py
86
87
  openhands/agent_server/bash_router.py
87
88
  openhands/agent_server/bash_service.py
89
+ openhands/agent_server/canvas_extensions_router.py
88
90
  openhands/agent_server/config.py
89
91
  openhands/agent_server/conversation_lease.py
90
92
  openhands/agent_server/conversation_router.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.43.0"
3
+ version = "1.44.0"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"