openhands-agent-server 1.23.0__tar.gz → 1.23.1__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.23.1}/PKG-INFO +1 -1
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/api.py +23 -3
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_service.py +72 -2
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/config.py +17 -2
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/event_service.py +56 -6
- openhands_agent_server-1.23.1/openhands/agent_server/middleware.py +94 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/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.23.1}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router_acp.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.23.
|
|
3
|
+
Version: 1.23.1
|
|
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.23.1}/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.23.1}/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=(
|
|
@@ -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)
|
|
@@ -778,6 +808,26 @@ class EventService:
|
|
|
778
808
|
self._run_task = None
|
|
779
809
|
await self._publish_state_update()
|
|
780
810
|
|
|
811
|
+
# Re-arm a run for input stranded while this task was
|
|
812
|
+
# wrapping up. A send_message(run=True) that arrived during
|
|
813
|
+
# the wait_for_pending() tail above had its run() rejected as
|
|
814
|
+
# "conversation_already_running" and suppressed, setting
|
|
815
|
+
# _rerun_requested. Honor it only while the conversation is
|
|
816
|
+
# still IDLE — i.e. that message is genuinely pending. If the
|
|
817
|
+
# run loop was still alive it already absorbed the message
|
|
818
|
+
# (LocalConversation.run() keeps looping on FINISHED) and we
|
|
819
|
+
# are FINISHED here, so the IDLE guard avoids a redundant run.
|
|
820
|
+
# A deliberate run=False append, or an IDLE reached via
|
|
821
|
+
# another path, never sets the flag.
|
|
822
|
+
if self._rerun_requested:
|
|
823
|
+
self._rerun_requested = False
|
|
824
|
+
if (
|
|
825
|
+
await self._get_execution_status()
|
|
826
|
+
== ConversationExecutionStatus.IDLE
|
|
827
|
+
):
|
|
828
|
+
with suppress(ValueError):
|
|
829
|
+
await self.run()
|
|
830
|
+
|
|
781
831
|
# Create task but don't await it - runs in background
|
|
782
832
|
self._run_task = asyncio.create_task(_run_and_publish())
|
|
783
833
|
|
|
@@ -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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.23.
|
|
3
|
+
Version: 1.23.1
|
|
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.23.1}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/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
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/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.23.1}/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.23.1}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/models.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/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.23.1}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/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.23.1}/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.23.1}/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
|