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.
Files changed (62) hide show
  1. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/api.py +23 -3
  3. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_service.py +72 -2
  4. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/config.py +17 -2
  5. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/event_service.py +56 -6
  6. openhands_agent_server-1.23.1/openhands/agent_server/middleware.py +94 -0
  7. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  8. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/pyproject.toml +1 -1
  9. openhands_agent_server-1.23.0/openhands/agent_server/middleware.py +0 -40
  10. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/__init__.py +0 -0
  11. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/__main__.py +0 -0
  12. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/_secrets_exposure.py +0 -0
  13. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/auth_router.py +0 -0
  14. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/bash_router.py +0 -0
  15. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/cloud_proxy_router.py +0 -0
  16. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_lease.py +0 -0
  17. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router.py +0 -0
  18. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_router_acp.py +0 -0
  19. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/conversation_service.py +0 -0
  20. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/dependencies.py +0 -0
  21. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_router.py +0 -0
  22. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/desktop_service.py +0 -0
  23. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/Dockerfile +0 -0
  24. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/build.py +0 -0
  25. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/docker/wallpaper.svg +0 -0
  26. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/env_parser.py +0 -0
  27. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/event_router.py +0 -0
  28. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/file_router.py +0 -0
  29. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/git_router.py +0 -0
  30. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_router.py +0 -0
  31. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/hooks_service.py +0 -0
  32. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/llm_router.py +0 -0
  33. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/logging_config.py +0 -0
  34. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/mcp_router.py +0 -0
  35. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/models.py +0 -0
  36. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/openapi.py +0 -0
  37. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/__init__.py +0 -0
  38. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/models.py +0 -0
  39. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/persistence/store.py +0 -0
  40. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/profiles_router.py +0 -0
  41. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/pub_sub.py +0 -0
  42. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/py.typed +0 -0
  43. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/server_details_router.py +0 -0
  44. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/settings_router.py +0 -0
  45. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_router.py +0 -0
  46. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/skills_service.py +0 -0
  47. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/sockets.py +0 -0
  48. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_preload_service.py +0 -0
  49. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/tool_router.py +0 -0
  50. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/utils.py +0 -0
  51. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  52. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  53. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_router.py +0 -0
  54. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/vscode_service.py +0 -0
  55. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/workspace_router.py +0 -0
  56. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands/agent_server/workspaces_router.py +0 -0
  57. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  58. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  59. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  60. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/requires.txt +0 -0
  61. {openhands_agent_server-1.23.0 → openhands_agent_server-1.23.1}/openhands_agent_server.egg-info/top_level.txt +0 -0
  62. {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.0
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
@@ -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 LocalhostCORSMiddleware
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(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
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
 
@@ -120,8 +120,10 @@ class Config(BaseModel):
120
120
  allow_cors_origins: list[str] = Field(
121
121
  default_factory=list,
122
122
  description=(
123
- "Set of CORS origins permitted by this server (Anything from localhost is "
124
- "always accepted regardless of what's in here)."
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
- # Already running or inactive — message was sent, skip run.
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
- state = self._conversation._state
531
- with state:
532
- event = ConversationStateUpdateEvent(key="stats", value=state.stats)
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.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.23.0"
3
+ version = "1.23.1"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"
@@ -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