openhands-agent-server 1.23.0__tar.gz → 1.24.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.23.0 → openhands_agent_server-1.24.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/api.py +23 -3
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/bash_service.py +72 -2
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/config.py +17 -2
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_router.py +46 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_service.py +56 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/event_service.py +92 -14
- openhands_agent_server-1.24.0/openhands/agent_server/middleware.py +94 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/models.py +49 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/pyproject.toml +1 -1
- openhands_agent_server-1.23.0/openhands/agent_server/middleware.py +0 -40
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/conversation_router_acp.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.24.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.24.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.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/api.py
RENAMED
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import tempfile
|
|
4
4
|
import traceback
|
|
5
5
|
from collections.abc import AsyncIterator, Sequence
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
from urllib.parse import urlparse
|
|
@@ -17,6 +17,7 @@ from starlette.requests import Request
|
|
|
17
17
|
|
|
18
18
|
from openhands.agent_server.auth_router import auth_router
|
|
19
19
|
from openhands.agent_server.bash_router import bash_router
|
|
20
|
+
from openhands.agent_server.bash_service import get_default_bash_event_service
|
|
20
21
|
from openhands.agent_server.cloud_proxy_router import cloud_proxy_router
|
|
21
22
|
from openhands.agent_server.config import (
|
|
22
23
|
Config,
|
|
@@ -39,7 +40,7 @@ from openhands.agent_server.git_router import git_router
|
|
|
39
40
|
from openhands.agent_server.hooks_router import hooks_router
|
|
40
41
|
from openhands.agent_server.llm_router import llm_router
|
|
41
42
|
from openhands.agent_server.mcp_router import mcp_router
|
|
42
|
-
from openhands.agent_server.middleware import
|
|
43
|
+
from openhands.agent_server.middleware import CORSDispatcher
|
|
43
44
|
from openhands.agent_server.profiles_router import profiles_router
|
|
44
45
|
from openhands.agent_server.server_details_router import (
|
|
45
46
|
get_server_info,
|
|
@@ -188,9 +189,28 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
|
|
|
188
189
|
async with service:
|
|
189
190
|
# Store the initialized service in app state for dependency injection
|
|
190
191
|
api.state.conversation_service = service
|
|
192
|
+
|
|
193
|
+
config = api.state.config
|
|
194
|
+
retention_task: asyncio.Task | None = None
|
|
195
|
+
if config.bash_events_retention_seconds is not None:
|
|
196
|
+
retention_task = asyncio.create_task(
|
|
197
|
+
get_default_bash_event_service().run_retention_cleanup_loop(
|
|
198
|
+
config.bash_events_retention_seconds
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
logger.info(
|
|
202
|
+
"Bash events retention cleanup started (retention: %ds)",
|
|
203
|
+
config.bash_events_retention_seconds,
|
|
204
|
+
)
|
|
205
|
+
|
|
191
206
|
try:
|
|
192
207
|
yield
|
|
193
208
|
finally:
|
|
209
|
+
if retention_task is not None:
|
|
210
|
+
retention_task.cancel()
|
|
211
|
+
with suppress(asyncio.CancelledError):
|
|
212
|
+
await retention_task
|
|
213
|
+
|
|
194
214
|
# Define async functions for stopping each service
|
|
195
215
|
async def stop_vscode_service():
|
|
196
216
|
if vscode_service is not None:
|
|
@@ -519,7 +539,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
519
539
|
|
|
520
540
|
_add_api_routes(app, config)
|
|
521
541
|
_setup_static_files(app, config)
|
|
522
|
-
app.add_middleware(
|
|
542
|
+
app.add_middleware(CORSDispatcher, allow_origins=config.allow_cors_origins)
|
|
523
543
|
_add_exception_handlers(app)
|
|
524
544
|
|
|
525
545
|
return app
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import signal
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ from openhands.agent_server.models import (
|
|
|
18
18
|
)
|
|
19
19
|
from openhands.agent_server.pub_sub import PubSub, Subscriber
|
|
20
20
|
from openhands.sdk.logger import get_logger
|
|
21
|
-
from openhands.sdk.utils import sanitized_env
|
|
21
|
+
from openhands.sdk.utils import sanitized_env, utc_now
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
logger = get_logger(__name__)
|
|
@@ -344,6 +344,76 @@ class BashEventService:
|
|
|
344
344
|
self._save_event_to_file(error_output)
|
|
345
345
|
await self._pub_sub(error_output)
|
|
346
346
|
|
|
347
|
+
def delete_events_older_than(self, cutoff: datetime) -> int:
|
|
348
|
+
"""Delete bash event files with a recorded timestamp older than ``cutoff``.
|
|
349
|
+
|
|
350
|
+
This is a synchronous method — all operations are blocking filesystem
|
|
351
|
+
I/O. Callers on the asyncio event loop should use
|
|
352
|
+
``await asyncio.to_thread(service.delete_events_older_than, cutoff)``
|
|
353
|
+
to avoid stalling the loop.
|
|
354
|
+
|
|
355
|
+
File names are prefixed with ``YYYYMMDDHHMMSS`` in ascending sort order,
|
|
356
|
+
so scanning stops as soon as a file at or after the cutoff is reached.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
int: The number of event files deleted.
|
|
360
|
+
"""
|
|
361
|
+
cutoff_str = self._timestamp_to_str(cutoff)
|
|
362
|
+
files = self._get_event_files_by_pattern("*") # ascending chronological order
|
|
363
|
+
count = 0
|
|
364
|
+
for path in files:
|
|
365
|
+
if path.name >= cutoff_str:
|
|
366
|
+
break # remaining files are at or newer than cutoff
|
|
367
|
+
try:
|
|
368
|
+
path.unlink(missing_ok=True)
|
|
369
|
+
count += 1
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning("Failed to delete bash event file %s: %s", path, e)
|
|
372
|
+
if count:
|
|
373
|
+
logger.info(
|
|
374
|
+
"Deleted %d bash event file(s) older than %s", count, cutoff_str
|
|
375
|
+
)
|
|
376
|
+
return count
|
|
377
|
+
|
|
378
|
+
async def run_retention_cleanup_loop(
|
|
379
|
+
self,
|
|
380
|
+
retention_seconds: int,
|
|
381
|
+
interval_seconds: float | None = None,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Periodically purge bash event files older than ``retention_seconds``.
|
|
384
|
+
|
|
385
|
+
Runs until cancelled (e.g. during application shutdown). Cleanup runs
|
|
386
|
+
immediately on entry so that files accumulated across a server restart
|
|
387
|
+
are purged without waiting for the first interval to elapse.
|
|
388
|
+
|
|
389
|
+
Blocking filesystem work is dispatched to a thread via
|
|
390
|
+
``asyncio.to_thread`` to keep the event loop free.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
retention_seconds: Age threshold in seconds; older files are deleted.
|
|
394
|
+
interval_seconds: How often to run the cleanup. Defaults to
|
|
395
|
+
``max(60, retention_seconds / 2)``. Pass a smaller value in
|
|
396
|
+
tests to avoid long waits.
|
|
397
|
+
"""
|
|
398
|
+
interval = (
|
|
399
|
+
interval_seconds
|
|
400
|
+
if interval_seconds is not None
|
|
401
|
+
else max(60.0, retention_seconds / 2)
|
|
402
|
+
)
|
|
403
|
+
while True:
|
|
404
|
+
try:
|
|
405
|
+
cutoff = utc_now() - timedelta(seconds=retention_seconds)
|
|
406
|
+
await asyncio.to_thread(self.delete_events_older_than, cutoff)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.warning("Bash events retention cleanup error: %s", e)
|
|
409
|
+
# Brief back-off to prevent log flooding if the failure is persistent
|
|
410
|
+
# (e.g. permission error, full disk). Cap at the normal interval so
|
|
411
|
+
# we don't over-delay in low-retention configurations.
|
|
412
|
+
await asyncio.sleep(min(interval, 60.0))
|
|
413
|
+
# Always sleep the full interval after the error back-off, so total
|
|
414
|
+
# wait on error = min(interval, 60) + interval ≈ 2× normal cadence.
|
|
415
|
+
await asyncio.sleep(interval)
|
|
416
|
+
|
|
347
417
|
async def subscribe_to_events(self, subscriber: Subscriber[BashEventBase]) -> UUID:
|
|
348
418
|
"""Subscribe to bash events.
|
|
349
419
|
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/config.py
RENAMED
|
@@ -120,8 +120,10 @@ class Config(BaseModel):
|
|
|
120
120
|
allow_cors_origins: list[str] = Field(
|
|
121
121
|
default_factory=list,
|
|
122
122
|
description=(
|
|
123
|
-
"
|
|
124
|
-
"always
|
|
123
|
+
"CORS origins permitted by this server. Localhost / 127.0.0.1 "
|
|
124
|
+
"and ``DOCKER_HOST_ADDR`` are always allowed. Does not apply to "
|
|
125
|
+
"the workspace cookie routes, which accept any origin — see "
|
|
126
|
+
"``middleware.py``."
|
|
125
127
|
),
|
|
126
128
|
)
|
|
127
129
|
conversations_path: Path = Field(
|
|
@@ -137,6 +139,19 @@ class Config(BaseModel):
|
|
|
137
139
|
"Defaults to 'workspace/bash_events'."
|
|
138
140
|
),
|
|
139
141
|
)
|
|
142
|
+
bash_events_retention_seconds: int | None = Field(
|
|
143
|
+
default=None,
|
|
144
|
+
gt=0,
|
|
145
|
+
description=(
|
|
146
|
+
"How long bash event files are retained on disk, in seconds. "
|
|
147
|
+
"A background task purges events older than this window on a "
|
|
148
|
+
"rolling basis. None (default) retains events indefinitely. "
|
|
149
|
+
"Should be set higher than the longest expected command timeout: "
|
|
150
|
+
"a command whose BashCommand file is purged mid-execution will "
|
|
151
|
+
"complete normally, but its on-disk event history will be "
|
|
152
|
+
"incomplete. A value >= 2x max command timeout avoids this."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
140
155
|
static_files_path: Path | None = Field(
|
|
141
156
|
default=None,
|
|
142
157
|
description=(
|
|
@@ -394,6 +394,52 @@ async def switch_conversation_llm(
|
|
|
394
394
|
return Success()
|
|
395
395
|
|
|
396
396
|
|
|
397
|
+
@conversation_router.post(
|
|
398
|
+
"/{conversation_id}/switch_acp_model",
|
|
399
|
+
responses={
|
|
400
|
+
400: {"description": "Agent is not ACP, or provider can't switch models"},
|
|
401
|
+
404: {"description": "Conversation not found"},
|
|
402
|
+
409: {"description": "ACP session not initialized yet"},
|
|
403
|
+
504: {"description": "ACP server did not answer the model switch in time"},
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
async def switch_conversation_acp_model(
|
|
407
|
+
conversation_id: UUID,
|
|
408
|
+
model: str = Body(..., embed=True),
|
|
409
|
+
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
410
|
+
) -> Success:
|
|
411
|
+
"""Switch the model of a running ACP conversation, mid-conversation.
|
|
412
|
+
|
|
413
|
+
Issues a protocol-level ``session/set_model`` call to the ACP subprocess
|
|
414
|
+
so the new model applies to subsequent turns without losing context. Only
|
|
415
|
+
valid for ACP conversations whose provider supports runtime switching.
|
|
416
|
+
"""
|
|
417
|
+
event_service = await conversation_service.get_event_service(conversation_id)
|
|
418
|
+
if event_service is None:
|
|
419
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
420
|
+
try:
|
|
421
|
+
await event_service.switch_acp_model(model)
|
|
422
|
+
except ValueError as e:
|
|
423
|
+
raise HTTPException(
|
|
424
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
425
|
+
detail=str(e),
|
|
426
|
+
)
|
|
427
|
+
except TimeoutError as e:
|
|
428
|
+
# The bounded session/set_model round-trip expired. The ACP server is
|
|
429
|
+
# wedged/slow rather than rejecting the request, so surface a 504
|
|
430
|
+
# instead of an opaque 500.
|
|
431
|
+
raise HTTPException(
|
|
432
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
433
|
+
detail=str(e),
|
|
434
|
+
)
|
|
435
|
+
except RuntimeError as e:
|
|
436
|
+
raise HTTPException(
|
|
437
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
438
|
+
detail=str(e),
|
|
439
|
+
)
|
|
440
|
+
return Success()
|
|
441
|
+
|
|
442
|
+
|
|
397
443
|
@conversation_router.patch(
|
|
398
444
|
"/{conversation_id}", responses={404: {"description": "Item not found"}}
|
|
399
445
|
)
|
|
@@ -245,12 +245,68 @@ def _compose_conversation_info(
|
|
|
245
245
|
# Use mode='json' so SecretStr in nested structures (e.g. LookupSecret.headers,
|
|
246
246
|
# agent.agent_context.secrets) serialize to strings. Without it, validation
|
|
247
247
|
# fails because ConversationInfo expects dict[str, str] but receives SecretStr.
|
|
248
|
+
#
|
|
249
|
+
# ACP model state is lifted onto top-level ConversationInfo fields because
|
|
250
|
+
# the agent holds it in PrivateAttrs (ACPAgent is frozen) which don't survive
|
|
251
|
+
# ``model_dump``. ``getattr`` keeps non-ACP agents a no-op. We read the live
|
|
252
|
+
# agent (fresh within a session) and fall back to ``state.agent_state`` —
|
|
253
|
+
# persisted to ``base_state.json`` by ``ACPAgent._init`` (and kept in sync by
|
|
254
|
+
# ``switch_acp_model``) — so cold list reads, where PrivateAttrs are still
|
|
255
|
+
# empty because ``init_state`` hasn't fired, still surface the last-known
|
|
256
|
+
# state. Persisted ``acp_available_models`` is a list of dicts that
|
|
257
|
+
# ``ConversationInfo`` coerces back into ``ACPModelInfo``.
|
|
258
|
+
agent_state = getattr(state, "agent_state", {}) or {}
|
|
259
|
+
agent = state.agent
|
|
260
|
+
# current_model_id: live PrivateAttr (fresh after a runtime switch) → the
|
|
261
|
+
# persisted hint → the authoritative ``acp_model`` the agent runs on resume.
|
|
262
|
+
#
|
|
263
|
+
# The ``acp_model`` fallback is gated on the agent NOT being a live,
|
|
264
|
+
# initialized one. Once ``init_state`` has fired, ``current_model_id`` is the
|
|
265
|
+
# authoritative resolved value — including ``None`` when an override couldn't
|
|
266
|
+
# be applied (unknown provider, or a resume whose ``set_session_model`` the
|
|
267
|
+
# server rejected) — so falling back to ``acp_model`` there would re-assert an
|
|
268
|
+
# override the live session isn't actually running. The fallback is only for
|
|
269
|
+
# *cold* reads (``init_state`` hasn't fired, PrivateAttrs still empty), where
|
|
270
|
+
# the serialized ``acp_model`` is the best last-known hint. The persisted
|
|
271
|
+
# ``acp_current_model_id`` hint is kept honest by ``ACPAgent.init_state`` (it
|
|
272
|
+
# clears the value whenever the override wasn't applied), so it's safe in
|
|
273
|
+
# both cases.
|
|
274
|
+
agent_initialized = bool(getattr(agent, "_initialized", False))
|
|
275
|
+
current_model_id = (
|
|
276
|
+
getattr(agent, "current_model_id", None)
|
|
277
|
+
or agent_state.get("acp_current_model_id")
|
|
278
|
+
or (None if agent_initialized else getattr(agent, "acp_model", None))
|
|
279
|
+
)
|
|
280
|
+
# available_models: the property returns ``[]`` (never ``None``) for *both* a
|
|
281
|
+
# cold-read agent (PrivateAttr default, init_state hasn't fired) and a live
|
|
282
|
+
# agent that genuinely has no models, so an ``is None`` check can't tell them
|
|
283
|
+
# apart — and would drop the persisted picker payload on every cold list
|
|
284
|
+
# read. The ``or`` chain is deliberate: an empty live list falls back to the
|
|
285
|
+
# persisted snapshot, which is exactly right on cold reads (surface the
|
|
286
|
+
# last-known list) and benign for a live empty session (the persisted value
|
|
287
|
+
# is itself empty/absent there).
|
|
288
|
+
available_models = (
|
|
289
|
+
getattr(agent, "available_models", None)
|
|
290
|
+
or agent_state.get("acp_available_models")
|
|
291
|
+
or []
|
|
292
|
+
)
|
|
293
|
+
# Static provider capability. Unlike the two fields above it has no
|
|
294
|
+
# meaningful live-vs-persisted distinction — it's derived from the stable
|
|
295
|
+
# provider identity and written once at session init — so we read the
|
|
296
|
+
# persisted value directly. Defaults False for non-ACP agents and
|
|
297
|
+
# conversations that haven't started a session.
|
|
298
|
+
supports_runtime_model_switch = bool(
|
|
299
|
+
agent_state.get("acp_supports_runtime_model_switch", False)
|
|
300
|
+
)
|
|
248
301
|
return ConversationInfo(
|
|
249
302
|
**state.model_dump(mode="json"),
|
|
250
303
|
title=stored.title,
|
|
251
304
|
metrics=stored.metrics,
|
|
252
305
|
created_at=stored.created_at,
|
|
253
306
|
updated_at=stored.updated_at,
|
|
307
|
+
current_model_id=current_model_id,
|
|
308
|
+
available_models=available_models,
|
|
309
|
+
supports_runtime_model_switch=supports_runtime_model_switch,
|
|
254
310
|
)
|
|
255
311
|
|
|
256
312
|
|
|
@@ -68,6 +68,9 @@ class EventService:
|
|
|
68
68
|
default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
|
|
69
69
|
)
|
|
70
70
|
_run_task: asyncio.Task | None = field(default=None, init=False)
|
|
71
|
+
# Set when a send_message(run=True) is rejected because a run is still
|
|
72
|
+
# wrapping up; consumed by _run_and_publish to re-run the stranded message.
|
|
73
|
+
_rerun_requested: bool = field(default=False, init=False)
|
|
71
74
|
_run_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
|
|
72
75
|
_callback_wrapper: AsyncCallbackWrapper | None = field(default=None, init=False)
|
|
73
76
|
_lease: ConversationLease | None = field(default=None, init=False)
|
|
@@ -419,9 +422,19 @@ class EventService:
|
|
|
419
422
|
loop = asyncio.get_running_loop()
|
|
420
423
|
await loop.run_in_executor(None, self._conversation.send_message, message)
|
|
421
424
|
if run:
|
|
422
|
-
|
|
423
|
-
with suppress(ValueError):
|
|
425
|
+
try:
|
|
424
426
|
await self.run()
|
|
427
|
+
except ValueError as e:
|
|
428
|
+
# run() refused. If a run is still wrapping up (its
|
|
429
|
+
# wait_for_pending tail), the message we just appended won't be
|
|
430
|
+
# picked up by it, so record explicit run intent for
|
|
431
|
+
# _run_and_publish to honor once that task clears. Tracking the
|
|
432
|
+
# request — rather than inferring it later from an IDLE status —
|
|
433
|
+
# is what keeps a deliberate run=False append, or an IDLE reached
|
|
434
|
+
# via another path, from triggering an unwanted run.
|
|
435
|
+
# "inactive_service" is terminal and must not re-arm.
|
|
436
|
+
if str(e) == "conversation_already_running":
|
|
437
|
+
self._rerun_requested = True
|
|
425
438
|
|
|
426
439
|
async def subscribe_to_events(self, subscriber: Subscriber[Event]) -> UUID:
|
|
427
440
|
subscriber_id = self._pub_sub.subscribe(subscriber)
|
|
@@ -523,13 +536,29 @@ class EventService:
|
|
|
523
536
|
"""Configure stats update callbacks to stream stats changes via events."""
|
|
524
537
|
|
|
525
538
|
def stats_callback() -> None:
|
|
526
|
-
"""Callback to emit stats updates.
|
|
539
|
+
"""Callback to emit stats updates.
|
|
540
|
+
|
|
541
|
+
Invoked synchronously by ``Telemetry.on_response`` (regular
|
|
542
|
+
Agent path) and ``ACPAgent._record_usage`` (ACP path) — both
|
|
543
|
+
run inside ``LocalConversation.run()``'s ``with self._state:``
|
|
544
|
+
block, so the caller already owns the conversation state lock.
|
|
545
|
+
|
|
546
|
+
DO NOT re-acquire the state lock here (``with state:``). It
|
|
547
|
+
looks safe — ``FIFOLock`` documents itself as reentrant — but
|
|
548
|
+
on the ACP code path it deadlocks (silently) before the rest
|
|
549
|
+
of ``step()`` can emit the assistant's FinishAction +
|
|
550
|
+
ObservationEvent, leaving every conversation hung in
|
|
551
|
+
``running`` status forever. ``_emit_event_from_thread`` below
|
|
552
|
+
already acquires the lock on the executor thread before
|
|
553
|
+
persisting the event; that's the only place serialization
|
|
554
|
+
needs the lock anyway.
|
|
555
|
+
"""
|
|
527
556
|
# Publish only the stats field to avoid sending entire state
|
|
528
557
|
if not self._conversation:
|
|
529
558
|
return
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
559
|
+
event = ConversationStateUpdateEvent(
|
|
560
|
+
key="stats", value=self._conversation._state.stats
|
|
561
|
+
)
|
|
533
562
|
self._emit_event_from_thread(event)
|
|
534
563
|
|
|
535
564
|
for llm in agent.get_all_llms():
|
|
@@ -646,6 +675,7 @@ class EventService:
|
|
|
646
675
|
cipher=self.cipher,
|
|
647
676
|
hook_config=self.stored.hook_config,
|
|
648
677
|
tags=self.stored.tags,
|
|
678
|
+
user_id=self.stored.user_id,
|
|
649
679
|
)
|
|
650
680
|
|
|
651
681
|
conversation.set_confirmation_policy(self.stored.confirmation_policy)
|
|
@@ -710,8 +740,9 @@ class EventService:
|
|
|
710
740
|
immediately. When possible, the conversation is driven via its native
|
|
711
741
|
``arun()`` coroutine so LLM I/O does not tie up a thread-pool worker.
|
|
712
742
|
For conversations that do not expose ``arun()`` (e.g., custom
|
|
713
|
-
subclasses)
|
|
714
|
-
|
|
743
|
+
subclasses) or whose agent only implements sync ``step()`` (no
|
|
744
|
+
``astep()`` override), the synchronous ``run()`` is executed
|
|
745
|
+
in the thread pool as before.
|
|
715
746
|
|
|
716
747
|
Raises:
|
|
717
748
|
ValueError: If the service is inactive or conversation is already running.
|
|
@@ -743,19 +774,23 @@ class EventService:
|
|
|
743
774
|
# loop is free during LLM I/O. Fall back to thread-pool
|
|
744
775
|
# execution for backward compatibility.
|
|
745
776
|
#
|
|
746
|
-
#
|
|
777
|
+
# All guards are required:
|
|
747
778
|
# • iscoroutinefunction – filters out non-async objects
|
|
748
779
|
# (e.g. MagicMock in tests).
|
|
749
|
-
# • override
|
|
750
|
-
# ``
|
|
751
|
-
#
|
|
752
|
-
#
|
|
753
|
-
#
|
|
780
|
+
# • conversation override – BaseConversation's default
|
|
781
|
+
# ``arun()`` delegates to sync ``run()``, so we require an
|
|
782
|
+
# *actual* override to avoid running a sync-only subclass
|
|
783
|
+
# on the event loop.
|
|
784
|
+
# • agent override – ``LocalConversation`` always overrides
|
|
785
|
+
# ``arun()``, but an agent without an ``astep()`` override
|
|
786
|
+
# runs sync ``step()`` in a worker thread; route it
|
|
787
|
+
# through sync ``run()`` instead.
|
|
754
788
|
arun = getattr(conversation, "arun", None)
|
|
755
789
|
has_native_arun = (
|
|
756
790
|
arun is not None
|
|
757
791
|
and asyncio.iscoroutinefunction(arun)
|
|
758
792
|
and type(conversation).arun is not BaseConversation.arun
|
|
793
|
+
and type(conversation.agent).astep is not AgentBase.astep
|
|
759
794
|
)
|
|
760
795
|
if has_native_arun:
|
|
761
796
|
await conversation.arun()
|
|
@@ -778,6 +813,26 @@ class EventService:
|
|
|
778
813
|
self._run_task = None
|
|
779
814
|
await self._publish_state_update()
|
|
780
815
|
|
|
816
|
+
# Re-arm a run for input stranded while this task was
|
|
817
|
+
# wrapping up. A send_message(run=True) that arrived during
|
|
818
|
+
# the wait_for_pending() tail above had its run() rejected as
|
|
819
|
+
# "conversation_already_running" and suppressed, setting
|
|
820
|
+
# _rerun_requested. Honor it only while the conversation is
|
|
821
|
+
# still IDLE — i.e. that message is genuinely pending. If the
|
|
822
|
+
# run loop was still alive it already absorbed the message
|
|
823
|
+
# (LocalConversation.run() keeps looping on FINISHED) and we
|
|
824
|
+
# are FINISHED here, so the IDLE guard avoids a redundant run.
|
|
825
|
+
# A deliberate run=False append, or an IDLE reached via
|
|
826
|
+
# another path, never sets the flag.
|
|
827
|
+
if self._rerun_requested:
|
|
828
|
+
self._rerun_requested = False
|
|
829
|
+
if (
|
|
830
|
+
await self._get_execution_status()
|
|
831
|
+
== ConversationExecutionStatus.IDLE
|
|
832
|
+
):
|
|
833
|
+
with suppress(ValueError):
|
|
834
|
+
await self.run()
|
|
835
|
+
|
|
781
836
|
# Create task but don't await it - runs in background
|
|
782
837
|
self._run_task = asyncio.create_task(_run_and_publish())
|
|
783
838
|
|
|
@@ -861,6 +916,29 @@ class EventService:
|
|
|
861
916
|
None, self._conversation.set_security_analyzer, security_analyzer
|
|
862
917
|
)
|
|
863
918
|
|
|
919
|
+
async def switch_acp_model(self, model: str) -> None:
|
|
920
|
+
"""Switch the model on a running ACP conversation, mid-conversation.
|
|
921
|
+
|
|
922
|
+
Runs the (blocking) protocol-level ``session/set_model`` round-trip in a
|
|
923
|
+
worker thread, then mirrors the new model into ``meta.json`` so the
|
|
924
|
+
switch survives an agent-server restart: ``start()`` rebuilds the agent
|
|
925
|
+
from ``self.stored.agent`` and ``ConversationState.create()`` copies
|
|
926
|
+
that over the persisted base_state.json on resume. Only ``acp_model``
|
|
927
|
+
needs updating — ``model_post_init`` re-derives the sentinel
|
|
928
|
+
``llm.model`` on reload.
|
|
929
|
+
"""
|
|
930
|
+
if self._conversation is None:
|
|
931
|
+
raise RuntimeError(
|
|
932
|
+
"Conversation is not active; it has not been started or has "
|
|
933
|
+
"been closed."
|
|
934
|
+
)
|
|
935
|
+
loop = asyncio.get_running_loop()
|
|
936
|
+
await loop.run_in_executor(None, self._conversation.switch_acp_model, model)
|
|
937
|
+
self.stored = self.stored.model_copy(
|
|
938
|
+
update={"agent": self.stored.agent.model_copy(update={"acp_model": model})}
|
|
939
|
+
)
|
|
940
|
+
await self.save_meta()
|
|
941
|
+
|
|
864
942
|
async def close(self):
|
|
865
943
|
if self._lease_task is not None:
|
|
866
944
|
self._lease_task.cancel()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""CORS middleware for the agent server.
|
|
2
|
+
|
|
3
|
+
``CORSDispatcher`` routes requests to one of two CORS configurations
|
|
4
|
+
based on path:
|
|
5
|
+
|
|
6
|
+
* Workspace cookie endpoints (``/api/auth/workspace-session`` and
|
|
7
|
+
``/api/conversations/{id}/workspace/*``) — wildcard CORS that echoes
|
|
8
|
+
the request Origin on every response. These are the only routes that
|
|
9
|
+
authenticate via an ambient (cookie) credential.
|
|
10
|
+
* Everything else — ``LocalhostCORSMiddleware``, which honors the
|
|
11
|
+
operator's ``allow_cors_origins`` and always allows localhost and
|
|
12
|
+
``DOCKER_HOST_ADDR`` (matches OpenHands/OpenHands#4624 intent).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
20
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_WORKSPACE_SESSION_PATH = "/api/auth/workspace-session"
|
|
24
|
+
_WORKSPACE_STATIC_RE = re.compile(r"^/api/conversations/[^/]+/workspace(/|$)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_workspace_cookie_path(path: str) -> bool:
|
|
28
|
+
if path == _WORKSPACE_SESSION_PATH:
|
|
29
|
+
return True
|
|
30
|
+
return bool(_WORKSPACE_STATIC_RE.match(path))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocalhostCORSMiddleware(CORSMiddleware):
|
|
34
|
+
"""``CORSMiddleware`` that always allows localhost and ``DOCKER_HOST_ADDR``."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
|
|
37
|
+
super().__init__(
|
|
38
|
+
app,
|
|
39
|
+
allow_origins=allow_origins,
|
|
40
|
+
allow_credentials=True,
|
|
41
|
+
allow_methods=["*"],
|
|
42
|
+
allow_headers=["*"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def is_allowed_origin(self, origin: str) -> bool:
|
|
46
|
+
if origin:
|
|
47
|
+
hostname = urlparse(origin).hostname or ""
|
|
48
|
+
if hostname in ("localhost", "127.0.0.1"):
|
|
49
|
+
return True
|
|
50
|
+
docker_host_addr = os.environ.get("DOCKER_HOST_ADDR")
|
|
51
|
+
if docker_host_addr and hostname == docker_host_addr:
|
|
52
|
+
return True
|
|
53
|
+
return bool(super().is_allowed_origin(origin))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CORSDispatcher:
|
|
57
|
+
"""Dispatches each request to the workspace or default CORS middleware.
|
|
58
|
+
|
|
59
|
+
The workspace branch uses ``allow_origin_regex=r"https?://.+"`` rather
|
|
60
|
+
than ``allow_origins=["*"]`` for two reasons:
|
|
61
|
+
|
|
62
|
+
1. Starlette emits a literal ``*`` on simple responses when
|
|
63
|
+
``allow_all_origins`` is set and the request has no ``Cookie``
|
|
64
|
+
header — which browsers reject together with
|
|
65
|
+
``Access-Control-Allow-Credentials: true``. The regex path always
|
|
66
|
+
echoes the request Origin (with ``Vary: Origin``).
|
|
67
|
+
2. Anchoring to ``http(s)://`` excludes ``Origin: null`` (sandboxed
|
|
68
|
+
iframes, ``data:`` / ``blob:`` URLs), which have no defined CHIPS
|
|
69
|
+
partition key and are not legitimate clients.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, app: ASGIApp, *, allow_origins: list[str]) -> None:
|
|
73
|
+
self._default_cors = LocalhostCORSMiddleware(
|
|
74
|
+
app, allow_origins=list(allow_origins)
|
|
75
|
+
)
|
|
76
|
+
self._workspace_cors = CORSMiddleware(
|
|
77
|
+
app,
|
|
78
|
+
allow_origin_regex=r"https?://.+",
|
|
79
|
+
allow_credentials=True,
|
|
80
|
+
allow_methods=["*"],
|
|
81
|
+
allow_headers=["*"],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
85
|
+
if scope.get("type") == "http":
|
|
86
|
+
# Strip FastAPI ``root_path`` so dispatch works behind
|
|
87
|
+
# reverse proxies mounted under a sub-path.
|
|
88
|
+
root_path = scope.get("root_path", "")
|
|
89
|
+
path = scope.get("path", "/")
|
|
90
|
+
route_path = path.removeprefix(root_path) if root_path else path
|
|
91
|
+
if _is_workspace_cookie_path(route_path or "/"):
|
|
92
|
+
await self._workspace_cors(scope, receive, send)
|
|
93
|
+
return
|
|
94
|
+
await self._default_cors(scope, receive, send)
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/models.py
RENAMED
|
@@ -9,6 +9,7 @@ from uuid import UUID, uuid4
|
|
|
9
9
|
from pydantic import BaseModel, Field, field_validator
|
|
10
10
|
|
|
11
11
|
from openhands.sdk import LLM
|
|
12
|
+
from openhands.sdk.agent.acp_models import ACPModelInfo
|
|
12
13
|
from openhands.sdk.agent.base import AgentBase
|
|
13
14
|
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
14
15
|
from openhands.sdk.conversation.request import ( # re-export for backward compat
|
|
@@ -184,6 +185,54 @@ class _ConversationInfoBase(BaseModel):
|
|
|
184
185
|
"alphanumeric. Values are arbitrary strings up to 256 characters."
|
|
185
186
|
),
|
|
186
187
|
)
|
|
188
|
+
current_model_id: str | None = Field(
|
|
189
|
+
default=None,
|
|
190
|
+
description=(
|
|
191
|
+
"Model the agent is actually using for this session. For ACP "
|
|
192
|
+
"agents, this is lifted off ``ACPAgent.current_model_id`` "
|
|
193
|
+
"(populated from the ``models.currentModelId`` field on the "
|
|
194
|
+
"ACP session response, or from ``acp_model`` when the caller "
|
|
195
|
+
"forced an override). May be an opaque alias (e.g. "
|
|
196
|
+
'claude-agent-acp\'s ``"default"``); match it against '
|
|
197
|
+
"``available_models`` to get a display label. ``None`` for older "
|
|
198
|
+
"ACP servers that don't surface the field, or while the agent is "
|
|
199
|
+
"still initializing. Native OpenHands agents leave this ``None`` — "
|
|
200
|
+
"consumers should read ``agent.llm.model`` for those."
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
available_models: list[ACPModelInfo] = Field(
|
|
204
|
+
default_factory=list,
|
|
205
|
+
description=(
|
|
206
|
+
"Models the ACP server offers for this session, lifted off "
|
|
207
|
+
"``ACPAgent.available_models`` (the ``models.availableModels`` "
|
|
208
|
+
"field on the ACP session response). Each entry carries a "
|
|
209
|
+
"``model_id`` plus an optional ``name``/``description``. Surfaced "
|
|
210
|
+
"verbatim so clients can render a model picker and resolve "
|
|
211
|
+
"``current_model_id`` to a display label themselves — the server "
|
|
212
|
+
"does no name curation. Empty for ACP servers that don't surface "
|
|
213
|
+
"the (UNSTABLE) capability and for native OpenHands agents. "
|
|
214
|
+
"Client contract: ``current_model_id`` is NOT guaranteed to be a "
|
|
215
|
+
"member — a forced ``acp_model`` override may name a model absent "
|
|
216
|
+
"from the list — so treat a miss as 'show the raw id'. Some "
|
|
217
|
+
"entries are opaque aliases whose human identity lives in "
|
|
218
|
+
'``description`` (e.g. claude-agent-acp\'s ``"default"`` -> '
|
|
219
|
+
'``"Opus 4.7 with 1M context · ..."``).'
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
supports_runtime_model_switch: bool = Field(
|
|
223
|
+
default=False,
|
|
224
|
+
description=(
|
|
225
|
+
"Whether a live, mid-conversation model switch (via "
|
|
226
|
+
"``session/set_model``) will be attempted for this conversation — "
|
|
227
|
+
"tells the inline picker whether to offer a live-switch control. "
|
|
228
|
+
"Mirrors the SDK's switch gate: ``True`` for known switch-capable "
|
|
229
|
+
"providers; ``True`` for unknown/custom ACP servers too, since "
|
|
230
|
+
"OpenHands attempts the switch optimistically rather than refusing "
|
|
231
|
+
"(a rejection then surfaces as an error). ``False`` for native "
|
|
232
|
+
"OpenHands agents, for a known provider that declares no support, "
|
|
233
|
+
"and before the conversation has started a session."
|
|
234
|
+
),
|
|
235
|
+
)
|
|
187
236
|
|
|
188
237
|
|
|
189
238
|
class ConversationInfo(_ConversationInfoBase):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.24.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
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from urllib.parse import urlparse
|
|
3
|
-
|
|
4
|
-
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
-
from starlette.types import ASGIApp
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class LocalhostCORSMiddleware(CORSMiddleware):
|
|
9
|
-
"""Custom CORS middleware that allows any request from localhost/127.0.0.1 domains.
|
|
10
|
-
|
|
11
|
-
Also allows the DOCKER_HOST_ADDR IP, while using standard CORS rules for
|
|
12
|
-
other origins.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
|
|
16
|
-
super().__init__(
|
|
17
|
-
app,
|
|
18
|
-
allow_origins=allow_origins,
|
|
19
|
-
allow_credentials=True,
|
|
20
|
-
allow_methods=["*"],
|
|
21
|
-
allow_headers=["*"],
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
def is_allowed_origin(self, origin: str) -> bool:
|
|
25
|
-
if origin and not self.allow_origins and not self.allow_origin_regex:
|
|
26
|
-
parsed = urlparse(origin)
|
|
27
|
-
hostname = parsed.hostname or ""
|
|
28
|
-
|
|
29
|
-
# Allow any localhost/127.0.0.1 origin regardless of port
|
|
30
|
-
if hostname in ["localhost", "127.0.0.1"]:
|
|
31
|
-
return True
|
|
32
|
-
|
|
33
|
-
# Also allow DOCKER_HOST_ADDR if set (for remote browser access)
|
|
34
|
-
docker_host_addr = os.environ.get("DOCKER_HOST_ADDR")
|
|
35
|
-
if docker_host_addr and hostname == docker_host_addr:
|
|
36
|
-
return True
|
|
37
|
-
|
|
38
|
-
# For missing origin or other origins, use the parent class's logic
|
|
39
|
-
result: bool = super().is_allowed_origin(origin)
|
|
40
|
-
return result
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.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.23.0 → openhands_agent_server-1.24.0}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.24.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
|
|
File without changes
|