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.
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/api.py +2 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/__init__.py +4 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/installed.py +41 -0
- openhands_agent_server-1.44.0/openhands/agent_server/canvas_extensions_router.py +291 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_service.py +73 -13
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/Dockerfile +75 -53
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/event_service.py +7 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/models.py +48 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/store.py +2 -2
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/SOURCES.txt +2 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/pyproject.toml +1 -1
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/_secret_redaction.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/agent_profiles_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/credential_binding.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/init_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/mcp_oauth_store.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openai/service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/plugins_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/plugins_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/provider_connections_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/sub_agents_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/__init__.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/factory.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/models.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/policy.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/sink.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/telemetry_types.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/api.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
154
|
-
#
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.43.0 → openhands_agent_server-1.44.0}/openhands/agent_server/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|