Compare commits

..

13 Commits

Author SHA1 Message Date
openhands
087669601a Add design doc for chat message persistence
Design document for PR 2 of 2: Message Queue with Offline Support

This PR addresses messages being lost when:
- WebSocket is disconnected
- Runtime is starting up
- User submits while reconnecting

See PR 1 (Draft Persistence) for the related draft preservation feature.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 04:13:51 +00:00
Tim O'Farrell
8b8ed5be96 fix: Revert on_conversation_update to load conversation inside method (#13368)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 19:08:04 -06:00
Tim O'Farrell
c1328f512d Upgrade the SDK to 1.13.0 (#13365) 2026-03-12 13:28:19 -06:00
Tim O'Farrell
e2805dea75 Fix pagination bug in event_service_base.search_events causing duplicate events in exports (#13364)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 12:24:06 -06:00
aivong-openhands
127e611706 Fix GHSA-78cv-mqj4-43f7: Update tornado to 6.5.5 (#13362)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-12 13:22:39 -05:00
Hiep Le
a176a135da fix: sdk conversations not appearing in cloud ui (#13296) 2026-03-12 22:23:08 +07:00
Tim O'Farrell
ab78d7d6e8 fix: Set correct user context in webhook callbacks based on sandbox owner (#13340)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 09:11:35 -06:00
mamoodi
4eb6e4da09 Release 1.5.0 (#13336) 2026-03-11 14:50:13 -04:00
dependabot[bot]
7e66304746 chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#13348)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 12:09:09 -05:00
Graham Neubig
a8b12e8eb8 Remove Common Room sync scripts (#13347)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 10:48:37 -04:00
Xingyao Wang
53bb82fe2e fix: use project_dir consistently for workspace.working_dir, setup.sh, and git hooks (#13329)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 15:26:34 +08:00
Tim O'Farrell
db40eb1e94 Using the web_url where it is configured rather than the request.url (#13319)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 13:11:33 -06:00
Hiep Le
debbaae385 fix(backend): inherit organization llm settings for new members (#13330) 2026-03-11 01:28:46 +07:00
51 changed files with 2804 additions and 2835 deletions

File diff suppressed because it is too large Load Diff

56
enterprise/poetry.lock generated
View File

@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
]
[package.dependencies]
@@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.4.0"
version = "1.5.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6259,9 +6259,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.3"
openhands-agent-server = "1.12"
openhands-sdk = "1.12"
openhands-tools = "1.12"
openhands-agent-server = "1.13"
openhands-sdk = "1.13"
openhands-tools = "1.13"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6315,14 +6315,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
]
[package.dependencies]
@@ -6345,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
]
[package.dependencies]
@@ -11599,14 +11599,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
]
[package.extras]
@@ -13771,24 +13771,22 @@ files = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
]
[[package]]

View File

@@ -27,7 +27,6 @@ from server.middleware import SetAuthCookieMiddleware # noqa: E402
from server.rate_limit import setup_rate_limit_handler # noqa: E402
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
from server.routes.auth import api_router, oauth_router # noqa: E402
from server.routes.automations import automation_router # noqa: E402
from server.routes.billing import billing_router # noqa: E402
from server.routes.email import api_router as email_router # noqa: E402
from server.routes.event_webhook import event_webhook_router # noqa: E402
@@ -140,8 +139,6 @@ if BITBUCKET_DATA_CENTER_HOST:
base_app.include_router(bitbucket_dc_proxy_router)
base_app.include_router(email_router) # Add routes for email management
base_app.include_router(feedback_router) # Add routes for conversation feedback
base_app.include_router(automation_router) # Add routes for automation CRUD
base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes

View File

@@ -12,11 +12,8 @@ from server.auth.auth_error import (
)
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth, token_manager
from server.routes.auth import (
get_cookie_domain,
get_cookie_samesite,
set_response_cookie,
)
from server.routes.auth import set_response_cookie
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
@@ -93,8 +90,8 @@ class SetAuthCookieMiddleware:
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
return response

View File

@@ -3,7 +3,7 @@ import json
import uuid
import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional, cast
from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid
@@ -27,7 +27,7 @@ from server.auth.user.user_authorizer import (
depends_user_authorizer,
)
from server.config import sign_token
from server.constants import IS_FEATURE_ENV
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from server.services.org_invitation_service import (
EmailMismatchError,
@@ -37,12 +37,12 @@ from server.services.org_invitation_service import (
UserAlreadyMemberError,
)
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
from sqlalchemy import select
from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
from openhands.app_server.config import get_global_config
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import ProviderType, TokenResponse
@@ -77,7 +77,7 @@ def set_response_cookie(
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
# Set secure cookie with signed token
domain = get_cookie_domain(request)
domain = get_cookie_domain()
if domain:
response.set_cookie(
key='keycloak_auth',
@@ -85,7 +85,7 @@ def set_response_cookie(
domain=domain,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(request),
samesite=get_cookie_samesite(),
)
else:
response.set_cookie(
@@ -93,30 +93,10 @@ def set_response_cookie(
value=signed_token,
httponly=True,
secure=secure,
samesite=get_cookie_samesite(request),
samesite=get_cookie_samesite(),
)
def get_cookie_domain(request: Request) -> str | None:
# for now just use the full hostname except for staging stacks.
return (
None
if not request.url.hostname
or request.url.hostname.endswith('staging.all-hands.dev')
else request.url.hostname
)
def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
return (
'lax'
if request.url.hostname == 'localhost'
or (request.url.hostname or '').endswith('staging.all-hands.dev')
else 'strict'
)
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
@@ -140,19 +120,6 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
return state, None, None
# Keep alias for backward compatibility
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Deprecated: Use _extract_oauth_state instead.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
return redirect_url, recaptcha_token
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -183,10 +150,7 @@ async def keycloak_callback(
detail='Missing code in request params',
)
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
web_url = get_web_url(request)
redirect_uri = web_url + request.url.path
(
@@ -313,7 +277,9 @@ async def keycloak_callback(
else:
raise
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
verification_redirect_url = (
f'{web_url}/login?email_verification_required=true&user_id={user_id}'
)
if rate_limited:
verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'
@@ -474,9 +440,7 @@ async def keycloak_callback(
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')
tos_redirect_url = (
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
)
tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
if invitation_token:
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
@@ -508,10 +472,9 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'Missing code in request params'},
)
scheme = 'https'
if request.url.hostname == 'localhost':
scheme = 'http'
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
web_url = get_web_url(request)
redirect_uri = web_url + request.url.path
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
(
@@ -533,15 +496,14 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
)
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(
redirect_url if redirect_url else request.base_url, status_code=302
)
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
@oauth_router.get('/github/callback')
async def github_dummy_callback(request: Request):
"""Callback for GitHub that just forwards the user to the app base URL."""
return RedirectResponse(request.base_url, status_code=302)
web_url = get_web_url(request)
return RedirectResponse(web_url, status_code=302)
@api_router.post('/authenticate')
@@ -563,8 +525,8 @@ async def authenticate(request: Request):
if keycloak_auth_cookie:
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
return response
@@ -588,7 +550,8 @@ async def accept_tos(request: Request):
# Get redirect URL from request body
body = await request.json()
redirect_url = body.get('redirect_url', str(request.base_url))
web_url = get_web_url(request)
redirect_url = body.get('redirect_url', str(web_url))
# Update user settings with TOS acceptance
accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -618,7 +581,7 @@ async def accept_tos(request: Request):
response=response,
keycloak_access_token=access_token.get_secret_value(),
keycloak_refresh_token=refresh_token.get_secret_value(),
secure=False if request.url.hostname == 'localhost' else True,
secure=not IS_LOCAL_ENV,
accepted_tos=True,
)
return response
@@ -635,8 +598,8 @@ async def logout(request: Request):
# Always delete the cookie regardless of what happens
response.delete_cookie(
key='keycloak_auth',
domain=get_cookie_domain(request),
samesite=get_cookie_samesite(request),
domain=get_cookie_domain(),
samesite=get_cookie_samesite(),
)
# Try to properly logout from Keycloak, but don't fail if it doesn't work

View File

@@ -1,59 +0,0 @@
"""Pydantic request/response models for automation CRUD API."""
from pydantic import BaseModel, Field
class CreateAutomationRequest(BaseModel):
"""Simple mode (Phase 1): form input → generated file."""
name: str = Field(min_length=1, max_length=200)
schedule: str # 5-field cron expression
timezone: str = 'UTC'
prompt: str = Field(min_length=1)
repository: str | None = None # e.g., "owner/repo"
branch: str | None = None
class UpdateAutomationRequest(BaseModel):
name: str | None = None
schedule: str | None = None
timezone: str | None = None
prompt: str | None = None
repository: str | None = None
branch: str | None = None
enabled: bool | None = None
class AutomationResponse(BaseModel):
id: str
name: str
enabled: bool
trigger_type: str
config: dict
file_url: str | None = None
last_triggered_at: str | None = None
created_at: str
updated_at: str
class AutomationRunResponse(BaseModel):
id: str
automation_id: str
conversation_id: str | None = None
status: str
error_detail: str | None = None
started_at: str | None = None
completed_at: str | None = None
created_at: str
class PaginatedAutomationsResponse(BaseModel):
items: list[AutomationResponse]
total: int
next_page_id: str | None = None
class PaginatedRunsResponse(BaseModel):
items: list[AutomationRunResponse]
total: int
next_page_id: str | None = None

View File

@@ -1,465 +0,0 @@
"""FastAPI router for automation CRUD API (Phase 1: simple mode only)."""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from services.automation_config import extract_config, validate_config
from services.automation_event_publisher import publish_automation_event
from services.automation_file_generator import generate_automation_file
from sqlalchemy import delete, func, select
from storage.automation import Automation, AutomationRun
from storage.database import a_session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id
from .automation_models import (
AutomationResponse,
AutomationRunResponse,
CreateAutomationRequest,
PaginatedAutomationsResponse,
PaginatedRunsResponse,
UpdateAutomationRequest,
)
automation_router = APIRouter(
prefix='/api/v1/automations',
tags=['automations'],
)
FILE_STORE_PREFIX = 'automations'
def _file_store_key(automation_id: str) -> str:
return f'{FILE_STORE_PREFIX}/{automation_id}/automation.py'
def _paginate(rows: list, limit: int, id_attr: str = 'id') -> tuple[list, str | None]:
"""Return (items, next_page_id) from an overfetched result set."""
if len(rows) > limit:
return rows[:limit], getattr(rows[limit], id_attr)
return rows, None
def _automation_to_response(automation: Automation) -> AutomationResponse:
return AutomationResponse(
id=automation.id,
name=automation.name,
enabled=automation.enabled,
trigger_type=automation.trigger_type,
config=automation.config or {},
file_url=None,
last_triggered_at=(
automation.last_triggered_at.isoformat()
if automation.last_triggered_at
else None
),
created_at=automation.created_at.isoformat() if automation.created_at else '',
updated_at=automation.updated_at.isoformat() if automation.updated_at else '',
)
def _run_to_response(run: AutomationRun) -> AutomationRunResponse:
return AutomationRunResponse(
id=run.id,
automation_id=run.automation_id,
conversation_id=run.conversation_id,
status=run.status,
error_detail=run.error_detail,
started_at=run.started_at.isoformat() if run.started_at else None,
completed_at=run.completed_at.isoformat() if run.completed_at else None,
created_at=run.created_at.isoformat() if run.created_at else '',
)
def _generate_and_validate_file(
name: str,
schedule: str,
timezone: str,
prompt: str,
repository: str | None = None,
branch: str | None = None,
) -> tuple[str, dict]:
"""Generate automation file, extract config, validate, and store prompt in config.
Returns (file_content, config_dict).
Raises HTTPException on validation failure.
"""
file_content = generate_automation_file(
name=name,
schedule=schedule,
timezone=timezone,
prompt=prompt,
repository=repository,
branch=branch,
)
config = extract_config(file_content)
try:
validate_config(config)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f'Invalid automation config: {e}',
)
# Store prompt in config so DB is the source of truth (not the file)
config['prompt'] = prompt
return file_content, config
@automation_router.post('', status_code=status.HTTP_201_CREATED)
async def create_automation(
request: CreateAutomationRequest,
user_id: str = Depends(get_user_id),
) -> AutomationResponse:
"""Create an automation from simple mode input (Phase 1).
Generates a .py file, uploads to object store, stores metadata in DB.
"""
file_content, config = _generate_and_validate_file(
name=request.name,
schedule=request.schedule,
timezone=request.timezone,
prompt=request.prompt,
repository=request.repository,
branch=request.branch,
)
automation_id = uuid.uuid4().hex
key = _file_store_key(automation_id)
try:
file_store.write(key, file_content)
except Exception:
logger.exception('Failed to upload automation file to object store')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to store automation file',
)
automation = Automation(
id=automation_id,
user_id=user_id,
name=request.name,
enabled=True,
config=config,
trigger_type='cron',
file_store_key=key,
)
async with a_session_maker() as session:
session.add(automation)
await session.commit()
await session.refresh(automation)
logger.info(
'Created automation',
extra={'automation_id': automation_id, 'user_id': user_id},
)
return _automation_to_response(automation)
@automation_router.get('/search')
async def search_automations(
user_id: str = Depends(get_user_id),
page_id: Annotated[
str | None,
Query(title='Cursor for pagination (automation ID)'),
] = None,
limit: Annotated[
int,
Query(title='Max results per page', gt=0, le=100),
] = 20,
) -> PaginatedAutomationsResponse:
"""List automations for the current user, paginated."""
async with a_session_maker() as session:
base_filter = select(Automation).where(Automation.user_id == user_id)
# Total count
count_q = select(func.count()).select_from(base_filter.subquery())
total = (await session.execute(count_q)).scalar() or 0
# Paginated query ordered by created_at desc
query = base_filter.order_by(Automation.created_at.desc())
if page_id:
cursor_row = (
await session.execute(
select(Automation.created_at).where(Automation.id == page_id)
)
).scalar()
if cursor_row is not None:
query = query.where(Automation.created_at < cursor_row)
query = query.limit(limit + 1)
result = await session.execute(query)
rows = list(result.scalars().all())
items, next_page_id = _paginate(rows, limit)
return PaginatedAutomationsResponse(
items=[_automation_to_response(a) for a in items],
total=total,
next_page_id=next_page_id,
)
@automation_router.get('/{automation_id}')
async def get_automation(
automation_id: str,
user_id: str = Depends(get_user_id),
) -> AutomationResponse:
"""Get a single automation by ID."""
async with a_session_maker() as session:
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
automation = result.scalars().first()
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
return _automation_to_response(automation)
@automation_router.patch('/{automation_id}')
async def update_automation(
automation_id: str,
request: UpdateAutomationRequest,
user_id: str = Depends(get_user_id),
) -> AutomationResponse:
"""Update an automation. Re-generates file if prompt/schedule/timezone/name changed."""
async with a_session_maker() as session:
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
automation = result.scalars().first()
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
updates = {
k: v
for k, v in request.model_dump(exclude_unset=True).items()
if v is not None
}
file_regen_fields = {'schedule', 'timezone', 'prompt', 'name'}
needs_regen = bool(updates.keys() & file_regen_fields)
if needs_regen:
current_config = automation.config or {}
current_triggers = current_config.get('triggers', {}).get('cron', {})
# Merge: use request values if provided, else fall back to current config
new_name = updates.get('name', automation.name)
new_schedule = updates.get(
'schedule', current_triggers.get('schedule', '')
)
new_timezone = updates.get(
'timezone', current_triggers.get('timezone', 'UTC')
)
prompt = updates.get('prompt', current_config.get('prompt', ''))
file_content, config = _generate_and_validate_file(
name=new_name,
schedule=new_schedule,
timezone=new_timezone,
prompt=prompt,
repository=updates.get('repository'),
branch=updates.get('branch'),
)
try:
file_store.write(automation.file_store_key, file_content)
except Exception:
logger.exception('Failed to upload updated automation file')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to store updated automation file',
)
automation.config = config
automation.name = new_name
if 'name' in updates and not needs_regen:
automation.name = updates['name']
if 'enabled' in updates:
automation.enabled = updates['enabled']
await session.commit()
await session.refresh(automation)
return _automation_to_response(automation)
@automation_router.delete('/{automation_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_automation(
automation_id: str,
user_id: str = Depends(get_user_id),
) -> None:
"""Delete an automation and all its runs."""
async with a_session_maker() as session:
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
automation = result.scalars().first()
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
file_key = automation.file_store_key
# Delete runs first
await session.execute(
delete(AutomationRun).where(AutomationRun.automation_id == automation_id)
)
await session.delete(automation)
await session.commit()
# Best-effort cleanup of file store (DB is source of truth)
try:
file_store.delete(file_key)
except Exception:
logger.warning(
'Failed to delete automation file from object store',
extra={'automation_id': automation_id},
)
@automation_router.post('/{automation_id}/run', status_code=status.HTTP_202_ACCEPTED)
async def trigger_manual_run(
automation_id: str,
user_id: str = Depends(get_user_id),
) -> dict:
"""Manually trigger an automation run."""
async with a_session_maker() as session:
result = await session.execute(
select(Automation).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
automation = result.scalars().first()
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
dedup_key = f'manual-{automation_id}-{uuid.uuid4().hex}'
await publish_automation_event(
session=session,
source_type='manual',
payload={'automation_id': automation_id},
dedup_key=dedup_key,
)
await session.commit()
return {'status': 'accepted', 'dedup_key': dedup_key}
@automation_router.get('/{automation_id}/runs')
async def list_automation_runs(
automation_id: str,
user_id: str = Depends(get_user_id),
page_id: Annotated[
str | None,
Query(title='Cursor for pagination (run ID)'),
] = None,
limit: Annotated[
int,
Query(title='Max results per page', gt=0, le=100),
] = 20,
) -> PaginatedRunsResponse:
"""List runs for an automation, paginated."""
# Verify ownership
async with a_session_maker() as session:
ownership = await session.execute(
select(Automation.id).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
if not ownership.scalar():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
base_filter = select(AutomationRun).where(
AutomationRun.automation_id == automation_id
)
count_q = select(func.count()).select_from(base_filter.subquery())
total = (await session.execute(count_q)).scalar() or 0
query = base_filter.order_by(AutomationRun.created_at.desc())
if page_id:
cursor_row = (
await session.execute(
select(AutomationRun.created_at).where(AutomationRun.id == page_id)
)
).scalar()
if cursor_row is not None:
query = query.where(AutomationRun.created_at < cursor_row)
query = query.limit(limit + 1)
result = await session.execute(query)
rows = list(result.scalars().all())
items, next_page_id = _paginate(rows, limit)
return PaginatedRunsResponse(
items=[_run_to_response(r) for r in items],
total=total,
next_page_id=next_page_id,
)
@automation_router.get('/{automation_id}/runs/{run_id}')
async def get_automation_run(
automation_id: str,
run_id: str,
user_id: str = Depends(get_user_id),
) -> AutomationRunResponse:
"""Get a single run detail."""
async with a_session_maker() as session:
# Verify ownership of the automation
ownership = await session.execute(
select(Automation.id).where(
Automation.id == automation_id,
Automation.user_id == user_id,
)
)
if not ownership.scalar():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Automation not found',
)
result = await session.execute(
select(AutomationRun).where(
AutomationRun.id == run_id,
AutomationRun.automation_id == automation_id,
)
)
run = result.scalars().first()
if not run:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Run not found',
)
return _run_to_response(run)

View File

@@ -11,8 +11,8 @@ from integrations import stripe_service
from pydantic import BaseModel
from server.constants import STRIPE_API_KEY
from server.logger import logger
from server.utils.url_utils import get_web_url
from sqlalchemy import select
from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
@@ -151,7 +151,7 @@ async def create_customer_setup_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Could not find or create customer for user',
)
base_url = _get_base_url(request)
base_url = get_web_url(request)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_info['customer_id'],
mode='setup',
@@ -170,7 +170,7 @@ async def create_checkout_session(
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
await validate_billing_enabled()
base_url = _get_base_url(request)
base_url = get_web_url(request)
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
if not customer_info:
raise HTTPException(
@@ -198,8 +198,8 @@ async def create_checkout_session(
saved_payment_method_options={
'payment_method_save': 'enabled',
},
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
success_url=f'{base_url}/api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}/api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
)
logger.info(
'created_stripe_checkout_session',
@@ -300,7 +300,7 @@ async def success_callback(session_id: str, request: Request):
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
)
@@ -325,17 +325,9 @@ async def cancel_callback(session_id: str, request: Request):
)
billing_session.status = 'cancelled'
billing_session.updated_at = datetime.now(UTC)
session.merge(billing_session)
await session.merge(billing_session)
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
f'{get_web_url(request)}/settings/billing?checkout=cancel', status_code=302
)
def _get_base_url(request: Request) -> URL:
# Never send any part of the credit card process over a non secure connection
base_url = request.base_url
if base_url.hostname != 'localhost':
base_url = base_url.replace(scheme='https')
return base_url

View File

@@ -7,8 +7,10 @@ from pydantic import BaseModel, field_validator
from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import IS_LOCAL_ENV
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_web_url
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -87,7 +89,7 @@ async def update_email(
response=response,
keycloak_access_token=user_auth.access_token.get_secret_value(),
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
secure=False if request.url.hostname == 'localhost' else True,
secure=not IS_LOCAL_ENV,
accepted_tos=user_auth.accepted_tos or False,
)
@@ -156,8 +158,8 @@ async def verified_email(request: Request):
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
redirect_uri = f'{get_web_url(request)}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)
# need to set auth cookie to the new tokens
@@ -180,11 +182,10 @@ async def verified_email(request: Request):
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
else:
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
redirect_uri = f'{get_web_url(request)}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')
await keycloak_admin.a_send_verify_email(
user_id=user_id,

View File

@@ -6,6 +6,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from server.utils.url_utils import get_web_url
from storage.api_key_store import ApiKeyStore
from storage.device_code_store import DeviceCodeStore
@@ -93,7 +94,7 @@ async def device_authorization(
expires_in=DEVICE_CODE_EXPIRES_IN,
)
base_url = str(http_request.base_url).rstrip('/')
base_url = get_web_url(http_request)
verification_uri = f'{base_url}/oauth/device/verify'
verification_uri_complete = (
f'{verification_uri}?user_code={device_code_entry.user_code}'

View File

@@ -365,14 +365,12 @@ class OrgInvitationService:
'Failed to set up organization access. Please try again.'
)
# Step 5: Add user to organization
from storage.org_member_store import OrgMemberStore as OMS
org_member_kwargs = OMS.get_kwargs_from_settings(settings)
# Don't override with org defaults - use invitation-specified role
org_member_kwargs.pop('llm_model', None)
org_member_kwargs.pop('llm_base_url', None)
# Step 4.5: Fetch organization to get its LLM settings
org = await OrgStore.get_org_by_id(invitation.org_id)
if not org:
raise InvitationInvalidError('Organization not found')
# Step 5: Add user to organization with inherited org LLM settings
# Get the llm_api_key as string (it's SecretStr | None in Settings)
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
@@ -384,6 +382,9 @@ class OrgInvitationService:
role_id=invitation.role_id,
llm_api_key=llm_api_key,
status='active',
llm_model=org.default_llm_model,
llm_base_url=org.default_llm_base_url,
max_iterations=org.default_max_iterations,
)
# Step 6: Mark invitation as accepted

View File

@@ -334,7 +334,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
await super().save_app_conversation_info(info)
# Get current user_id for SAAS metadata
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
user_id_str = await self.user_context.get_user_id()
if not user_id_str and info.created_by_user_id:
user_id_str = info.created_by_user_id
if user_id_str:
# Convert string user_id to UUID
user_id_uuid = UUID(user_id_str)

View File

@@ -0,0 +1,38 @@
from typing import Literal
from fastapi import Request
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV, IS_STAGING_ENV
from starlette.datastructures import URL
from openhands.app_server.config import get_global_config
def get_web_url(request: Request):
web_url = get_global_config().web_url
if not web_url:
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
web_url = f'{scheme}://{request.url.netloc}'
else:
web_url = web_url.rstrip('/')
return web_url
def get_cookie_domain() -> str | None:
config = get_global_config()
web_url = config.web_url
# for now just use the full hostname except for staging stacks.
return (
URL(web_url).hostname
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else None
)
def get_cookie_samesite() -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
web_url = get_global_config().web_url
return (
'strict'
if web_url and not (IS_FEATURE_ENV or IS_STAGING_ENV or IS_LOCAL_ENV)
else 'lax'
)

View File

@@ -1,52 +0,0 @@
"""Automation config extraction and validation.
NOTE: This is a stub for Task 2 (CRUD API) development.
Task 1 (Data Foundation) will provide the full implementation.
"""
import ast
from pydantic import BaseModel, Field
class CronTriggerModel(BaseModel):
schedule: str = Field(pattern=r'^(\S+\s+){4}\S+$')
timezone: str = 'UTC'
class TriggersModel(BaseModel):
cron: CronTriggerModel | None = None
def model_post_init(self, __context: object) -> None:
defined = [k for k in ('cron',) if getattr(self, k) is not None]
if len(defined) != 1:
raise ValueError(f'Exactly one trigger required, got: {defined or "none"}')
class AutomationConfigModel(BaseModel):
name: str = Field(min_length=1, max_length=200)
triggers: TriggersModel
description: str = ''
def extract_config(source: str) -> dict:
"""Extract __config__ dict from a Python automation file using AST."""
tree = ast.parse(source)
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == '__config__':
return ast.literal_eval(node.value)
if isinstance(node, ast.AnnAssign):
if (
isinstance(node.target, ast.Name)
and node.target.id == '__config__'
and node.value is not None
):
return ast.literal_eval(node.value)
raise ValueError('No __config__ dict found in automation file')
def validate_config(config: dict) -> AutomationConfigModel:
"""Validate a __config__ dict. Returns parsed model or raises ValidationError."""
return AutomationConfigModel.model_validate(config)

View File

@@ -1,26 +0,0 @@
"""Automation event publisher.
NOTE: This is a stub for Task 2 (CRUD API) development.
Task 1 (Data Foundation) will provide the full implementation.
"""
from typing import Any
from storage.automation_event import AutomationEvent
async def publish_automation_event(
session: Any,
source_type: str,
payload: dict,
dedup_key: str,
) -> AutomationEvent:
"""Insert a new automation event into the automation_events table."""
event = AutomationEvent(
source_type=source_type,
payload=payload,
dedup_key=dedup_key,
status='NEW',
)
session.add(event)
return event

View File

@@ -1,49 +0,0 @@
"""Automation file generator for simple mode (Phase 1).
NOTE: This is a stub for Task 2 (CRUD API) development.
Task 1 (Data Foundation) will provide the full implementation.
"""
import json
PROMPT_TEMPLATE = '''\
"""{name} — auto-generated from form input."""
__config__ = {config_json}
import os
from openhands.sdk import LLM, Conversation
from openhands.tools.preset.default import get_default_agent
llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
)
agent = get_default_agent(llm=llm, cli_mode=True)
conversation = Conversation(agent=agent, workspace=os.getcwd())
conversation.send_message({prompt!r})
conversation.run()
'''
def generate_automation_file(
name: str,
schedule: str,
timezone: str,
prompt: str,
repository: str | None = None,
branch: str | None = None,
) -> str:
"""Generate a Python automation file from form input."""
config: dict = {
'name': name,
'triggers': {'cron': {'schedule': schedule, 'timezone': timezone}},
}
return PROMPT_TEMPLATE.format(
name=name,
config_json=json.dumps(config, indent=4),
prompt=prompt,
)

View File

@@ -1,47 +0,0 @@
"""SQLAlchemy models for automations.
NOTE: This is a stub for Task 2 (CRUD API) development.
Task 1 (Data Foundation) will provide the full implementation.
"""
from sqlalchemy import JSON, Boolean, Column, DateTime, String
from sqlalchemy.sql import func
from storage.base import Base
class Automation(Base): # type: ignore
__tablename__ = 'automations'
id = Column(String, primary_key=True)
user_id = Column(String, nullable=False, index=True)
org_id = Column(String, nullable=True, index=True)
name = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
config = Column(JSON, nullable=False)
trigger_type = Column(String, nullable=False)
file_store_key = Column(String, nullable=False)
last_triggered_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
class AutomationRun(Base): # type: ignore
__tablename__ = 'automation_runs'
id = Column(String, primary_key=True)
automation_id = Column(String, nullable=False, index=True)
conversation_id = Column(String, nullable=True)
status = Column(String, nullable=False, default='PENDING')
error_detail = Column(String, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -1,25 +0,0 @@
"""SQLAlchemy model for automation events.
NOTE: This is a stub for Task 2 (CRUD API) development.
Task 1 (Data Foundation) will provide the full implementation.
"""
from sqlalchemy import JSON, BigInteger, Column, DateTime, String
from sqlalchemy.sql import func
from storage.base import Base
class AutomationEvent(Base): # type: ignore
__tablename__ = 'automation_events'
id = Column(BigInteger, primary_key=True, autoincrement=True)
source_type = Column(String, nullable=False)
payload = Column(JSON, nullable=False)
metadata_ = Column('metadata', JSON, nullable=True)
dedup_key = Column(String, nullable=False, unique=True)
status = Column(String, nullable=False, default='NEW')
error_detail = Column(String, nullable=True)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
processed_at = Column(DateTime(timezone=True), nullable=True)

View File

@@ -28,6 +28,9 @@ class OrgMemberStore:
role_id: int,
llm_api_key: str,
status: Optional[str] = None,
llm_model: Optional[str] = None,
llm_base_url: Optional[str] = None,
max_iterations: Optional[int] = None,
) -> OrgMember:
"""Add a user to an organization with a specific role."""
async with a_session_maker() as session:
@@ -37,6 +40,9 @@ class OrgMemberStore:
role_id=role_id,
llm_api_key=llm_api_key,
status=status,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
)
session.add(org_member)
await session.commit()

View File

@@ -187,6 +187,18 @@ class SaasSettingsStore(SettingsStore):
if hasattr(model, key):
setattr(model, key, value)
# Map Settings fields to Org fields with 'default_' prefix
# The generic loop above doesn't update these because Org uses
# 'default_llm_model' not 'llm_model', etc.
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
settings_data = item.model_dump(exclude_unset=True)
if 'llm_model' in settings_data:
org.default_llm_model = settings_data['llm_model']
if 'llm_base_url' in settings_data:
org.default_llm_base_url = settings_data['llm_base_url']
if 'max_iterations' in settings_data:
org.default_max_iterations = settings_data['max_iterations']
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
# Note: Concurrent saves by multiple admins will result in last-write-wins.

View File

@@ -1,562 +0,0 @@
#!/usr/bin/env python3
"""
Common Room Sync
This script queries the database to count conversations created by each user,
then creates or updates a signal in Common Room for each user with their
conversation count.
"""
import asyncio
import logging
import os
import sys
import time
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional, Set
import requests
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.auth.token_manager import get_keycloak_admin
from storage.database import get_engine
# Configure logging
logging.basicConfig(
level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('common_room_sync')
# Common Room API configuration
COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY')
COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID')
COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1'
# Sync configuration
BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100'))
KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20'))
MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3'))
INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1'))
MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60'))
BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2'))
RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second
class CommonRoomSyncError(Exception):
"""Base exception for Common Room sync errors."""
class DatabaseError(CommonRoomSyncError):
"""Exception for database errors."""
class CommonRoomAPIError(CommonRoomSyncError):
"""Exception for Common Room API errors."""
class KeycloakClientError(CommonRoomSyncError):
"""Exception for Keycloak client errors."""
def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
"""Get conversations created in the past N minutes.
Args:
minutes: Number of minutes to look back for new conversations.
Returns:
A list of dictionaries, each containing conversation details.
Raises:
DatabaseError: If the database query fails.
"""
try:
# Use a different syntax for the interval that works with pg8000
query = text("""
SELECT
conversation_id, user_id, title, created_at
FROM
conversation_metadata
WHERE
created_at >= NOW() - (INTERVAL '1 minute' * :minutes)
ORDER BY
created_at DESC
""")
with get_engine().connect() as connection:
result = connection.execute(query, {'minutes': minutes})
conversations = [
{
'conversation_id': row[0],
'user_id': row[1],
'title': row[2],
'created_at': row[3].isoformat() if row[3] else None,
}
for row in result
]
logger.info(
f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes'
)
return conversations
except Exception as e:
logger.exception(f'Error querying recent conversations: {e}')
raise DatabaseError(f'Failed to query recent conversations: {e}')
async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]:
"""Get user information from Keycloak for a set of user IDs.
Args:
user_ids: A set of user IDs to look up.
Returns:
A dictionary mapping user IDs to user information dictionaries.
Raises:
KeycloakClientError: If the Keycloak API call fails.
"""
try:
# Get Keycloak admin client
keycloak_admin = get_keycloak_admin()
# Create a dictionary to store user information
user_info_dict = {}
# Convert set to list for easier batching
user_id_list = list(user_ids)
# Process user IDs in batches
for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE):
batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE]
batch_tasks = []
# Create tasks for each user ID in the batch
for user_id in batch:
# Use the Keycloak admin client to get user by ID
batch_tasks.append(get_user_by_id(keycloak_admin, user_id))
# Run the batch of tasks concurrently
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
# Process the results
for user_id, result in zip(batch, batch_results):
if isinstance(result, Exception):
logger.warning(f'Error getting user {user_id}: {result}')
continue
if result and isinstance(result, dict):
user_info_dict[user_id] = {
'username': result.get('username'),
'email': result.get('email'),
'id': result.get('id'),
}
logger.info(
f'Retrieved information for {len(user_info_dict)} users from Keycloak'
)
return user_info_dict
except Exception as e:
error_msg = f'Error getting users from Keycloak: {e}'
logger.exception(error_msg)
raise KeycloakClientError(error_msg)
async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]:
"""Get a user from Keycloak by ID.
Args:
keycloak_admin: The Keycloak admin client.
user_id: The user ID to look up.
Returns:
A dictionary with the user's information, or None if not found.
"""
try:
# Use the Keycloak admin client to get user by ID
user = keycloak_admin.get_user(user_id)
if user:
logger.debug(
f"Found user in Keycloak: {user.get('username')}, {user.get('email')}"
)
return user
else:
logger.warning(f'User {user_id} not found in Keycloak')
return None
except Exception as e:
logger.warning(f'Error getting user {user_id} from Keycloak: {e}')
return None
def get_user_info(
user_id: str, user_info_cache: Dict[str, Dict[str, Any]]
) -> Optional[Dict[str, str]]:
"""Get the email address and GitHub username for a user from the cache.
Args:
user_id: The user ID to look up.
user_info_cache: A dictionary mapping user IDs to user information.
Returns:
A dictionary with the user's email and username, or None if not found.
"""
# Check if the user is in the cache
if user_id in user_info_cache:
user_info = user_info_cache[user_id]
logger.debug(
f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}"
)
return user_info
else:
logger.warning(f'User {user_id} not found in user info cache')
return None
def register_user_in_common_room(
user_id: str, email: str, github_username: str
) -> Dict[str, Any]:
"""Create or update a user in Common Room.
Args:
user_id: The user ID.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Create or update user in Common Room
user_data = {
'id': user_id,
'email': email,
'username': github_username,
'github': {'type': 'handle', 'value': github_username},
}
user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user'
user_response = requests.post(user_url, headers=headers, json=user_data)
if user_response.status_code not in (200, 202):
logger.error(
f'Failed to create/update user in Common Room: {user_response.text}'
)
logger.error(f'Response status code: {user_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create/update user: {user_response.text}'
)
logger.info(
f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room'
)
return user_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def register_conversation_activity(
user_id: str,
conversation_id: str,
conversation_title: str,
created_at: datetime,
email: str,
github_username: str,
) -> Dict[str, Any]:
"""Create an activity in Common Room for a new conversation.
Args:
user_id: The user ID who created the conversation.
conversation_id: The ID of the conversation.
conversation_title: The title of the conversation.
created_at: The datetime object when the conversation was created.
email: The user's email address.
github_username: The user's GitHub username.
Returns:
The API response from Common Room.
Raises:
CommonRoomAPIError: If the Common Room API request fails.
"""
if not COMMON_ROOM_API_KEY:
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
raise CommonRoomAPIError(
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
)
try:
headers = {
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
'Content-Type': 'application/json',
}
# Format the datetime object to the expected ISO format
formatted_timestamp = (
created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
if created_at
else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
)
# Create activity for the conversation
activity_data = {
'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness
'activityType': 'started_session',
'user': {
'id': user_id,
'email': email,
'github': {'type': 'handle', 'value': github_username},
'username': github_username,
},
'activityTitle': {
'type': 'text',
'value': conversation_title or 'New Conversation',
},
'content': {
'type': 'text',
'value': f'Started a new conversation: {conversation_title or "Untitled"}',
},
'timestamp': formatted_timestamp,
'url': f'https://app.all-hands.dev/conversations/{conversation_id}',
}
# Log the activity data for debugging
logger.info(f'Activity data payload: {activity_data}')
activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity'
activity_response = requests.post(
activity_url, headers=headers, json=activity_data
)
if activity_response.status_code not in (200, 202):
logger.error(
f'Failed to create activity in Common Room: {activity_response.text}'
)
logger.error(f'Response status code: {activity_response.status_code}')
raise CommonRoomAPIError(
f'Failed to create activity: {activity_response.text}'
)
logger.info(
f'Registered conversation activity for user {user_id}, conversation {conversation_id}'
)
return activity_response.json()
except requests.RequestException as e:
logger.exception(f'Error communicating with Common Room API: {e}')
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
def retry_with_backoff(func, *args, **kwargs):
"""Retry a function with exponential backoff.
Args:
func: The function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
time.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def retry_with_backoff_async(func, *args, **kwargs):
"""Retry an async function with exponential backoff.
Args:
func: The async function to retry.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function call.
Raises:
The last exception raised by the function.
"""
backoff = INITIAL_BACKOFF_SECONDS
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
if attempt < MAX_RETRIES - 1:
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
await asyncio.sleep(sleep_time)
backoff *= BACKOFF_FACTOR
else:
logger.exception(f'All {MAX_RETRIES} attempts failed')
raise last_exception
async def async_sync_recent_conversations_to_common_room(minutes: int = 60):
"""Async main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
logger.info(
f'Starting Common Room recent conversations sync (past {minutes} minutes)'
)
stats = {
'total_conversations': 0,
'registered_users': 0,
'registered_activities': 0,
'errors': 0,
'missing_user_info': 0,
}
try:
# Get conversations created in the past N minutes
recent_conversations = retry_with_backoff(get_recent_conversations, minutes)
stats['total_conversations'] = len(recent_conversations)
logger.info(f'Processing {len(recent_conversations)} recent conversations')
if not recent_conversations:
logger.info('No recent conversations found, exiting')
return
# Extract all unique user IDs
user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']}
# Get user information for all users in batches
user_info_cache = await retry_with_backoff_async(
get_users_from_keycloak, user_ids
)
# Track registered users to avoid duplicate registrations
registered_users = set()
# Process each conversation
for conversation in recent_conversations:
conversation_id = conversation['conversation_id']
user_id = conversation['user_id']
title = conversation['title']
created_at = conversation[
'created_at'
] # This might be a string or datetime object
try:
# Get user info from cache
user_info = get_user_info(user_id, user_info_cache)
if not user_info:
logger.warning(
f'Could not find user info for user {user_id}, skipping conversation {conversation_id}'
)
stats['missing_user_info'] += 1
continue
email = user_info['email']
github_username = user_info['username']
if not email:
logger.warning(
f'User {user_id} has no email, skipping conversation {conversation_id}'
)
stats['errors'] += 1
continue
# Register user in Common Room if not already registered in this run
if user_id not in registered_users:
register_user_in_common_room(user_id, email, github_username)
registered_users.add(user_id)
stats['registered_users'] += 1
# If created_at is a string, parse it to a datetime object
# If it's already a datetime object, use it as is
# If it's None, use current time
created_at_datetime = (
created_at
if isinstance(created_at, datetime)
else datetime.fromisoformat(created_at.replace('Z', '+00:00'))
if created_at
else datetime.now(UTC)
)
# Register conversation activity with email and github username
register_conversation_activity(
user_id,
conversation_id,
title,
created_at_datetime,
email,
github_username,
)
stats['registered_activities'] += 1
# Sleep to respect rate limit
await asyncio.sleep(1 / RATE_LIMIT)
except Exception as e:
logger.exception(
f'Error processing conversation {conversation_id} for user {user_id}: {e}'
)
stats['errors'] += 1
except Exception as e:
logger.exception(f'Sync failed: {e}')
raise
finally:
logger.info(f'Sync completed. Stats: {stats}')
def sync_recent_conversations_to_common_room(minutes: int = 60):
"""Main function to sync recent conversations to Common Room.
Args:
minutes: Number of minutes to look back for new conversations.
"""
# Run the async function in the event loop
asyncio.run(async_sync_recent_conversations_to_common_room(minutes))
if __name__ == '__main__':
# Default to looking back 60 minutes for new conversations
minutes = int(os.environ.get('SYNC_MINUTES', '60'))
sync_recent_conversations_to_common_room(minutes)

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""
Test script for Common Room conversation count sync.
This script tests the functionality of the Common Room sync script
without making any API calls to Common Room or database connections.
"""
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sync.common_room_sync import (
retry_with_backoff,
)
class TestCommonRoomSync(unittest.TestCase):
"""Test cases for Common Room sync functionality."""
def test_retry_with_backoff(self):
"""Test the retry_with_backoff function."""
# Mock function that succeeds on the second attempt
mock_func = MagicMock(
side_effect=[Exception('First attempt failed'), 'success']
)
# Set environment variables for testing
with patch.dict(
os.environ,
{
'MAX_RETRIES': '3',
'INITIAL_BACKOFF_SECONDS': '0.01',
'BACKOFF_FACTOR': '2',
'MAX_BACKOFF_SECONDS': '1',
},
):
result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1')
# Check that the function was called twice
self.assertEqual(mock_func.call_count, 2)
# Check that the function was called with the correct arguments
mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1')
# Check that the function returned the expected result
self.assertEqual(result, 'success')
if __name__ == '__main__':
unittest.main()

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Test script to verify the conversation count query.
This script tests the database query to count conversations by user,
without making any API calls to Common Room.
"""
import os
import sys
from sqlalchemy import text
# Add the parent directory to the path so we can import from storage
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from storage.database import get_engine
def test_conversation_count_query():
"""Test the query to count conversations by user."""
try:
# Query to count conversations by user
count_query = text("""
SELECT
user_id, COUNT(*) as conversation_count
FROM
conversation_metadata
GROUP BY
user_id
""")
engine = get_engine()
with engine.connect() as connection:
count_result = connection.execute(count_query)
user_counts = [
{'user_id': row[0], 'conversation_count': row[1]}
for row in count_result
]
print(f'Found {len(user_counts)} users with conversations')
# Print the first 5 results
for i, user_data in enumerate(user_counts[:5]):
print(
f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations"
)
# Test the user_entity query for the first user (if any)
if user_counts:
first_user_id = user_counts[0]['user_id']
user_query = text("""
SELECT username, email, id
FROM user_entity
WHERE id = :user_id
""")
with engine.connect() as connection:
user_result = connection.execute(user_query, {'user_id': first_user_id})
user_row = user_result.fetchone()
if user_row:
print(f'\nUser details for {first_user_id}:')
print(f' GitHub Username: {user_row[0]}')
print(f' Email: {user_row[1]}')
print(f' ID: {user_row[2]}')
else:
print(
f'\nNo user details found for {first_user_id} in user_entity table'
)
print('\nTest completed successfully')
except Exception as e:
print(f'Error: {str(e)}')
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
test_conversation_count_query()

View File

@@ -1,653 +0,0 @@
"""Unit tests for automation CRUD API routes."""
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from server.routes.automations import automation_router
from openhands.server.user_auth import get_user_id
TEST_USER_ID = str(uuid.uuid4())
OTHER_USER_ID = str(uuid.uuid4())
def _make_automation(
automation_id: str | None = None,
user_id: str = TEST_USER_ID,
name: str = 'Test Automation',
enabled: bool = True,
trigger_type: str = 'cron',
schedule: str = '0 9 * * 5',
timezone: str = 'UTC',
file_store_key: str | None = None,
):
auto_id = automation_id or uuid.uuid4().hex
mock = MagicMock()
mock.id = auto_id
mock.user_id = user_id
mock.name = name
mock.enabled = enabled
mock.trigger_type = trigger_type
mock.config = {
'name': name,
'triggers': {'cron': {'schedule': schedule, 'timezone': timezone}},
}
mock.file_store_key = file_store_key or f'automations/{auto_id}/automation.py'
mock.last_triggered_at = None
mock.created_at = datetime(2026, 1, 1, tzinfo=UTC)
mock.updated_at = datetime(2026, 1, 1, tzinfo=UTC)
return mock
def _make_run(
run_id: str | None = None,
automation_id: str = 'auto-1',
conversation_id: str | None = None,
run_status: str = 'PENDING',
):
rid = run_id or uuid.uuid4().hex
mock = MagicMock()
mock.id = rid
mock.automation_id = automation_id
mock.conversation_id = conversation_id
mock.status = run_status
mock.error_detail = None
mock.started_at = None
mock.completed_at = None
mock.created_at = datetime(2026, 1, 2, tzinfo=UTC)
return mock
# --- Helpers to mock async DB sessions ---
def _mock_session_with_results(results_by_call):
"""Create a mock async session that returns preconfigured results.
results_by_call: list of values; each session.execute() returns
the next value wrapped in a mock result.
"""
call_index = [0]
session = AsyncMock()
async def _execute(stmt):
idx = call_index[0]
call_index[0] += 1
val = results_by_call[idx] if idx < len(results_by_call) else None
result_mock = MagicMock()
if isinstance(val, list):
result_mock.scalars.return_value.all.return_value = val
result_mock.scalars.return_value.first.return_value = (
val[0] if val else None
)
result_mock.scalar.return_value = len(val)
elif val is None:
result_mock.scalars.return_value.first.return_value = None
result_mock.scalars.return_value.all.return_value = []
result_mock.scalar.return_value = None
else:
result_mock.scalars.return_value.first.return_value = val
result_mock.scalar.return_value = val
return result_mock
session.execute = AsyncMock(side_effect=_execute)
session.commit = AsyncMock()
session.refresh = AsyncMock()
session.delete = AsyncMock()
session.add = MagicMock()
return session
@asynccontextmanager
async def _session_ctx(session):
yield session
# --- Fixtures ---
@pytest.fixture
def mock_app():
"""Create a test FastAPI app with automation routes and mocked auth."""
app = FastAPI()
app.include_router(automation_router)
def mock_get_user_id():
return TEST_USER_ID
app.dependency_overrides[get_user_id] = mock_get_user_id
return app
@pytest.fixture
def client(mock_app):
return TestClient(mock_app)
# --- Test: POST /api/v1/automations ---
class TestCreateAutomation:
def test_create_success(self, client):
"""POST with valid input → 201 with AutomationResponse."""
mock_session = _mock_session_with_results([])
async def fake_refresh(obj):
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
obj.updated_at = datetime(2026, 1, 1, tzinfo=UTC)
obj.last_triggered_at = None
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
with (
patch(
'server.routes.automations.generate_automation_file',
return_value='__config__ = {"name": "Test", "triggers": {"cron": {"schedule": "0 9 * * 5"}}}',
),
patch(
'server.routes.automations.extract_config',
return_value={
'name': 'Test',
'triggers': {'cron': {'schedule': '0 9 * * 5'}},
},
),
patch('server.routes.automations.validate_config'),
patch('server.routes.automations.file_store') as mock_fs,
patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
),
):
response = client.post(
'/api/v1/automations',
json={
'name': 'Test',
'schedule': '0 9 * * 5',
'prompt': 'Summarize PRs',
},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data['name'] == 'Test'
assert data['enabled'] is True
assert data['trigger_type'] == 'cron'
assert 'id' in data
mock_fs.write.assert_called_once()
def test_create_missing_name(self, client):
"""POST with missing name → 422."""
response = client.post(
'/api/v1/automations',
json={'schedule': '0 9 * * 5', 'prompt': 'Test'},
)
assert response.status_code == 422
def test_create_empty_name(self, client):
"""POST with empty name → 422."""
response = client.post(
'/api/v1/automations',
json={'name': '', 'schedule': '0 9 * * 5', 'prompt': 'Test'},
)
assert response.status_code == 422
def test_create_missing_prompt(self, client):
"""POST with missing prompt → 422."""
response = client.post(
'/api/v1/automations',
json={'name': 'Test', 'schedule': '0 9 * * 5'},
)
assert response.status_code == 422
def test_create_invalid_config_rejected(self, client):
"""POST where validate_config raises → 422."""
with (
patch(
'server.routes.automations.generate_automation_file',
return_value='__config__ = {}',
),
patch(
'server.routes.automations.extract_config',
return_value={},
),
patch(
'server.routes.automations.validate_config',
side_effect=ValueError('Invalid cron expression'),
),
):
response = client.post(
'/api/v1/automations',
json={
'name': 'Bad Cron',
'schedule': 'not-a-cron',
'prompt': 'Test',
},
)
assert response.status_code == 422
assert 'Invalid automation config' in response.json()['detail']
# --- Test: GET /api/v1/automations/search ---
class TestSearchAutomations:
def test_list_returns_user_automations(self, client):
"""GET /search → returns only current user's automations."""
a1 = _make_automation(name='Auto 1')
a2 = _make_automation(name='Auto 2')
# Session calls: count query → 2, paginated query → [a1, a2]
mock_session = _mock_session_with_results([2, [a1, a2]])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/search')
assert response.status_code == 200
data = response.json()
assert data['total'] == 2
assert len(data['items']) == 2
assert data['items'][0]['name'] == 'Auto 1'
def test_list_empty(self, client):
"""GET /search when no automations → empty list."""
mock_session = _mock_session_with_results([0, []])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/search')
assert response.status_code == 200
data = response.json()
assert data['total'] == 0
assert data['items'] == []
assert data['next_page_id'] is None
# --- Test: GET /api/v1/automations/{id} ---
class TestGetAutomation:
def test_get_existing(self, client):
"""GET existing automation → 200."""
auto = _make_automation(automation_id='auto-123')
mock_session = _mock_session_with_results([auto])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/auto-123')
assert response.status_code == 200
assert response.json()['id'] == 'auto-123'
def test_get_nonexistent(self, client):
"""GET non-existent automation → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/does-not-exist')
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test: PATCH /api/v1/automations/{id} ---
class TestUpdateAutomation:
def test_update_name_and_enabled(self, client):
"""PATCH with name + enabled → updates fields, returns 200."""
auto = _make_automation(automation_id='auto-123')
mock_session = _mock_session_with_results([auto])
async def fake_refresh(obj):
obj.name = 'Updated Name'
obj.enabled = False
obj.id = 'auto-123'
obj.trigger_type = 'cron'
obj.config = auto.config
obj.last_triggered_at = None
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
obj.updated_at = datetime(2026, 1, 2, tzinfo=UTC)
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
with (
patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
),
patch(
'server.routes.automations.generate_automation_file',
return_value='__config__ = {}',
),
patch(
'server.routes.automations.extract_config',
return_value=auto.config,
),
patch('server.routes.automations.validate_config'),
patch('server.routes.automations.file_store') as mock_fs,
):
mock_fs.read.return_value = (
'conversation.send_message("old prompt")\nconversation.run()'
)
response = client.patch(
'/api/v1/automations/auto-123',
json={'name': 'Updated Name', 'enabled': False},
)
assert response.status_code == 200
data = response.json()
assert data['name'] == 'Updated Name'
assert data['enabled'] is False
def test_update_nonexistent(self, client):
"""PATCH non-existent → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.patch(
'/api/v1/automations/nope',
json={'name': 'X'},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_prompt_regenerates_file(self, client):
"""PATCH with new prompt → re-generates file and uploads."""
auto = _make_automation(automation_id='auto-123')
mock_session = _mock_session_with_results([auto])
async def fake_refresh(obj):
obj.id = 'auto-123'
obj.name = auto.name
obj.enabled = auto.enabled
obj.trigger_type = 'cron'
obj.config = auto.config
obj.last_triggered_at = None
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
obj.updated_at = datetime(2026, 1, 2, tzinfo=UTC)
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
with (
patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
),
patch(
'server.routes.automations.generate_automation_file',
return_value='__config__ = {}',
) as mock_gen,
patch(
'server.routes.automations.extract_config',
return_value=auto.config,
),
patch('server.routes.automations.validate_config'),
patch('server.routes.automations.file_store') as mock_fs,
):
response = client.patch(
'/api/v1/automations/auto-123',
json={'prompt': 'New prompt text'},
)
assert response.status_code == 200
mock_gen.assert_called_once()
mock_fs.write.assert_called_once()
# --- Test: DELETE /api/v1/automations/{id} ---
class TestDeleteAutomation:
def test_delete_existing(self, client):
"""DELETE existing → 204."""
auto = _make_automation(automation_id='auto-123')
# First execute: select automation, second: delete runs
mock_session = _mock_session_with_results([auto, None])
with (
patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
),
patch('server.routes.automations.file_store'),
):
response = client.delete('/api/v1/automations/auto-123')
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_delete_nonexistent(self, client):
"""DELETE non-existent → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.delete('/api/v1/automations/nope')
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test: POST /api/v1/automations/{id}/run ---
class TestManualTrigger:
def test_manual_trigger_success(self, client):
"""POST .../run on existing automation → 202."""
auto = _make_automation(automation_id='auto-123')
mock_session = _mock_session_with_results([auto])
with (
patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
),
patch(
'server.routes.automations.publish_automation_event',
new_callable=AsyncMock,
) as mock_pub,
):
response = client.post('/api/v1/automations/auto-123/run')
assert response.status_code == status.HTTP_202_ACCEPTED
data = response.json()
assert data['status'] == 'accepted'
assert 'dedup_key' in data
assert data['dedup_key'].startswith('manual-auto-123-')
mock_pub.assert_called_once()
def test_manual_trigger_nonexistent(self, client):
"""POST .../run on non-existent → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.post('/api/v1/automations/nope/run')
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test: GET /api/v1/automations/{id}/runs ---
class TestListRuns:
def test_list_runs_success(self, client):
"""GET .../runs → paginated list."""
r1 = _make_run(run_id='run-1', automation_id='auto-123')
r2 = _make_run(run_id='run-2', automation_id='auto-123')
# Calls: ownership check → 'auto-123', count → 2, paginated → [r1, r2]
mock_session = _mock_session_with_results(['auto-123', 2, [r1, r2]])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/auto-123/runs')
assert response.status_code == 200
data = response.json()
assert data['total'] == 2
assert len(data['items']) == 2
def test_list_runs_automation_not_found(self, client):
"""GET .../runs for non-existent automation → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/nope/runs')
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test: GET /api/v1/automations/{id}/runs/{run_id} ---
class TestGetRun:
def test_get_run_success(self, client):
"""GET single run → 200."""
run = _make_run(run_id='run-1', automation_id='auto-123')
# Calls: ownership check → 'auto-123', select run → run
mock_session = _mock_session_with_results(['auto-123', run])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/auto-123/runs/run-1')
assert response.status_code == 200
assert response.json()['id'] == 'run-1'
def test_get_run_not_found(self, client):
"""GET non-existent run → 404."""
mock_session = _mock_session_with_results(['auto-123', None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/auto-123/runs/nope')
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_run_automation_not_found(self, client):
"""GET run for non-existent automation → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = client.get('/api/v1/automations/nope/runs/run-1')
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test: User Isolation (security) ---
class TestUserIsolation:
"""Verify that user A cannot access, update, or delete user B's automations.
The routes filter by user_id from the auth dependency, so automations owned by
another user should never be returned (the DB query uses WHERE user_id = <caller>).
We simulate this by having the mock session return None for cross-user lookups.
"""
@pytest.fixture
def other_user_app(self):
"""App configured to authenticate as OTHER_USER_ID."""
app = FastAPI()
app.include_router(automation_router)
def mock_get_other_user_id():
return OTHER_USER_ID
app.dependency_overrides[get_user_id] = mock_get_other_user_id
return app
@pytest.fixture
def other_client(self, other_user_app):
return TestClient(other_user_app)
def test_cannot_get_other_users_automation(self, other_client):
"""User B cannot GET user A's automation → 404."""
# The query filters by user_id=OTHER_USER_ID, so it won't find user A's row
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = other_client.get('/api/v1/automations/auto-owned-by-a')
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_cannot_update_other_users_automation(self, other_client):
"""User B cannot PATCH user A's automation → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = other_client.patch(
'/api/v1/automations/auto-owned-by-a',
json={'name': 'Hijacked'},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_cannot_delete_other_users_automation(self, other_client):
"""User B cannot DELETE user A's automation → 404."""
mock_session = _mock_session_with_results([None])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = other_client.delete('/api/v1/automations/auto-owned-by-a')
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_search_returns_empty_for_other_user(self, other_client):
"""User B's search returns empty even if user A has automations."""
# count=0, rows=[]
mock_session = _mock_session_with_results([0, []])
with patch(
'server.routes.automations.a_session_maker',
return_value=_session_ctx(mock_session),
):
response = other_client.get('/api/v1/automations/search')
assert response.status_code == 200
data = response.json()
assert data['total'] == 0
assert data['items'] == []

View File

@@ -1,264 +0,0 @@
"""Integration tests for automation CRUD API using a real in-memory SQLite database.
These tests exercise actual SQL queries (list, get, create+verify, pagination, delete)
rather than mocking the database layer.
"""
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from server.routes.automations import automation_router
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from storage.automation import Automation, AutomationRun
from openhands.app_server.utils.sql_utils import Base
from openhands.server.user_auth import get_user_id
TEST_USER_ID = 'integration-test-user'
OTHER_USER_ID = 'other-user'
@pytest.fixture
async def db_engine():
engine = create_async_engine('sqlite+aiosqlite://', echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
def session_maker(db_engine):
return async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture
def app(session_maker):
"""FastAPI app wired to a real SQLite database."""
app = FastAPI()
app.include_router(automation_router)
app.dependency_overrides[get_user_id] = lambda: TEST_USER_ID
@asynccontextmanager
async def _session_ctx():
async with session_maker() as session:
yield session
with patch('server.routes.automations.a_session_maker', _session_ctx):
yield app
@pytest.fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
def _make_automation_obj(
user_id: str = TEST_USER_ID,
name: str = 'Test Auto',
created_at: datetime | None = None,
**kwargs,
) -> Automation:
return Automation(
id=kwargs.get('automation_id', uuid.uuid4().hex),
user_id=user_id,
name=name,
enabled=kwargs.get('enabled', True),
config=kwargs.get(
'config',
{
'name': name,
'triggers': {'cron': {'schedule': '0 9 * * 5', 'timezone': 'UTC'}},
'prompt': 'Do something',
},
),
trigger_type='cron',
file_store_key=kwargs.get('file_store_key', f'automations/{uuid.uuid4().hex}/automation.py'),
created_at=created_at or datetime.now(UTC),
updated_at=created_at or datetime.now(UTC),
)
# ---------- Test: list (search) returns correct results ----------
@pytest.mark.asyncio
async def test_search_returns_user_automations(client, session_maker):
"""GET /search returns only automations owned by the requesting user."""
async with session_maker() as session:
a1 = _make_automation_obj(name='Auto A', created_at=datetime(2026, 1, 1, tzinfo=UTC))
a2 = _make_automation_obj(name='Auto B', created_at=datetime(2026, 1, 2, tzinfo=UTC))
a_other = _make_automation_obj(user_id=OTHER_USER_ID, name='Other User Auto')
session.add_all([a1, a2, a_other])
await session.commit()
response = await client.get('/api/v1/automations/search')
assert response.status_code == 200
data = response.json()
assert data['total'] == 2
assert len(data['items']) == 2
names = {item['name'] for item in data['items']}
assert names == {'Auto A', 'Auto B'}
# ---------- Test: get returns the right object ----------
@pytest.mark.asyncio
async def test_get_returns_correct_automation(client, session_maker):
"""GET /{id} returns the correct automation by ID."""
auto_id = uuid.uuid4().hex
async with session_maker() as session:
auto = _make_automation_obj(automation_id=auto_id, name='Specific Auto')
session.add(auto)
await session.commit()
response = await client.get(f'/api/v1/automations/{auto_id}')
assert response.status_code == 200
data = response.json()
assert data['id'] == auto_id
assert data['name'] == 'Specific Auto'
@pytest.mark.asyncio
async def test_get_nonexistent_returns_404(client):
"""GET /{id} for non-existent automation returns 404."""
response = await client.get('/api/v1/automations/does-not-exist')
assert response.status_code == 404
# ---------- Test: create + verify in DB ----------
@pytest.mark.asyncio
async def test_create_stores_in_db(client, session_maker):
"""POST creates an automation and it's readable from the database."""
mock_file_store = MagicMock()
config = {
'name': 'New Auto',
'triggers': {'cron': {'schedule': '0 9 * * 5', 'timezone': 'UTC'}},
}
with (
patch(
'server.routes.automations.generate_automation_file',
return_value='__config__ = {}',
),
patch('server.routes.automations.extract_config', return_value=config),
patch('server.routes.automations.validate_config'),
patch('server.routes.automations.file_store', mock_file_store),
):
response = await client.post(
'/api/v1/automations',
json={
'name': 'New Auto',
'schedule': '0 9 * * 5',
'prompt': 'Summarize PRs',
},
)
assert response.status_code == 201
data = response.json()
created_id = data['id']
# Verify it's in the DB via the GET endpoint
get_response = await client.get(f'/api/v1/automations/{created_id}')
assert get_response.status_code == 200
assert get_response.json()['name'] == 'New Auto'
# Verify prompt is stored in config
assert get_response.json()['config'].get('prompt') == 'Summarize PRs'
# ---------- Test: delete actually deletes ----------
@pytest.mark.asyncio
async def test_delete_removes_from_db(client, session_maker):
"""DELETE removes the automation from the database."""
auto_id = uuid.uuid4().hex
async with session_maker() as session:
auto = _make_automation_obj(automation_id=auto_id, name='To Delete')
session.add(auto)
await session.commit()
mock_file_store = MagicMock()
with patch('server.routes.automations.file_store', mock_file_store):
response = await client.delete(f'/api/v1/automations/{auto_id}')
assert response.status_code == 204
# Verify it's gone
get_response = await client.get(f'/api/v1/automations/{auto_id}')
assert get_response.status_code == 404
# ---------- Test: pagination actually works ----------
@pytest.mark.asyncio
async def test_pagination_returns_correct_pages(client, session_maker):
"""Pagination with limit returns correct page sizes and next_page_id."""
base_time = datetime(2026, 1, 1, tzinfo=UTC)
async with session_maker() as session:
for i in range(5):
auto = _make_automation_obj(
name=f'Auto {i}',
created_at=base_time + timedelta(hours=i),
)
session.add(auto)
await session.commit()
# First page with limit=2
response = await client.get('/api/v1/automations/search?limit=2')
assert response.status_code == 200
data = response.json()
assert data['total'] == 5
assert len(data['items']) == 2
assert data['next_page_id'] is not None
# Second page using cursor — should return remaining items before cursor
next_id = data['next_page_id']
response2 = await client.get(f'/api/v1/automations/search?limit=2&page_id={next_id}')
assert response2.status_code == 200
data2 = response2.json()
assert len(data2['items']) == 2
# Collect all items from both pages and verify no duplicates
all_ids = [item['id'] for item in data['items']] + [
item['id'] for item in data2['items']
]
assert len(all_ids) == len(set(all_ids)), 'Pages must not contain duplicate items'
# ---------- Test: user isolation at DB level ----------
@pytest.mark.asyncio
async def test_user_isolation(client, session_maker):
"""User A cannot see or access User B's automations via actual DB queries."""
auto_id = uuid.uuid4().hex
async with session_maker() as session:
other_auto = _make_automation_obj(
automation_id=auto_id,
user_id=OTHER_USER_ID,
name='Other User Auto',
)
session.add(other_auto)
await session.commit()
# Should not be found by TEST_USER_ID
response = await client.get(f'/api/v1/automations/{auto_id}')
assert response.status_code == 404
# Should not appear in search
search_response = await client.get('/api/v1/automations/search')
assert search_response.status_code == 200
assert search_response.json()['total'] == 0

View File

@@ -10,6 +10,9 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from server.utils.saas_app_conversation_info_injector import (
SaasSQLAppConversationInfoService,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
@@ -17,9 +20,6 @@ from storage.base import Base
from storage.org import Org
from storage.user import User
from enterprise.server.utils.saas_app_conversation_info_injector import (
SaasSQLAppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
@@ -663,3 +663,131 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 5
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
"""Test suite for webhook callback fallback using info.created_by_user_id."""
@pytest.mark.asyncio
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
This is the key fix for SDK-created conversations: when the webhook endpoint
uses ADMIN context (user_id=None), the service should fall back to using
the created_by_user_id from the AppConversationInfo object.
"""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID), # This should be used as fallback
sandbox_id='sandbox_webhook_test',
title='Webhook Created Conversation',
)
# Act: Save using ADMIN context
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None, 'SAAS metadata should be created'
assert (
saas_metadata.user_id == USER1_ID
), 'user_id should match info.created_by_user_id'
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
@pytest.mark.asyncio
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
self,
async_session_with_users: AsyncSession,
):
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Create service with ADMIN context (user_id=None)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Create conversation info without created_by_user_id
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=None, # No user_id available
sandbox_id='sandbox_no_user',
title='No User Conversation',
)
# Act: Save using ADMIN context with no user_id fallback
await admin_service.save_app_conversation_info(conv_info)
# Assert: SAAS metadata should NOT be created
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert (
saas_metadata is None
), 'SAAS metadata should not be created without user_id'
@pytest.mark.asyncio
async def test_webhook_created_conversation_visible_to_user(
self,
async_session_with_users: AsyncSession,
):
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Arrange: Save conversation using ADMIN context (simulating webhook)
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_webhook_e2e',
title='E2E Webhook Conversation',
)
await admin_service.save_app_conversation_info(conv_info)
# Act: Query as the owning user
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
user1_page = await user1_service.search_app_conversation_info()
# Assert: User should see the webhook-created conversation
assert len(user1_page.items) == 1
assert user1_page.items[0].id == conv_id
assert user1_page.items[0].title == 'E2E Webhook Conversation'

View File

@@ -11,7 +11,6 @@ from server.auth.auth_error import AuthError
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.user.user_authorizer import UserAuthorizationResponse, UserAuthorizer
from server.routes.auth import (
_extract_recaptcha_state,
accept_tos,
authenticate,
keycloak_callback,
@@ -55,11 +54,12 @@ def mock_response():
def test_set_response_cookie(mock_response, mock_request):
"""Test setting the auth cookie on a response."""
with patch('server.routes.auth.config') as mock_config:
with (
patch('server.routes.auth.config') as mock_config,
patch('server.utils.url_utils.get_global_config') as get_global_config,
):
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
# Configure mock_request.url.hostname
mock_request.url.hostname = 'example.com'
get_global_config.return_value = MagicMock(web_url='https://example.com')
set_response_cookie(
request=mock_request,
@@ -1036,79 +1036,6 @@ async def test_keycloak_callback_no_email_in_user_info(
mock_token_manager.check_duplicate_base_email.assert_not_called()
class TestExtractRecaptchaState:
"""Tests for _extract_recaptcha_state() helper function."""
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
"""Test extraction from new base64-encoded JSON format."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == 'https://example.com'
assert token == 'test-token'
def test_should_handle_old_format_plain_redirect_url(self):
"""Test handling of old format (plain redirect URL string)."""
# Arrange
state = 'https://example.com'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == 'https://example.com'
assert token is None
def test_should_handle_none_state(self):
"""Test handling of None state."""
# Arrange
state = None
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == ''
assert token is None
def test_should_handle_invalid_base64_gracefully(self):
"""Test handling of invalid base64/JSON (fallback to old format)."""
# Arrange
state = 'not-valid-base64!!!'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == state
assert token is None
def test_should_handle_missing_redirect_url_in_json(self):
"""Test handling when redirect_url is missing in JSON."""
# Arrange
state_data = {'recaptcha_token': 'test-token'}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == ''
assert token == 'test-token'
class TestKeycloakCallbackRecaptcha:
"""Tests for reCAPTCHA integration in keycloak_callback()."""

View File

@@ -48,7 +48,7 @@ def mock_checkout_request():
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
request._url = URL('http://test.com/')
return request
@@ -62,7 +62,7 @@ def mock_subscription_request():
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
request._url = URL('http://test.com/')
return request
@@ -264,7 +264,7 @@ async def test_create_checkout_session_success(
async def test_success_callback_session_not_found(async_session_maker):
"""Test success callback when billing session is not found."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
with (
patch('server.routes.billing.a_session_maker', async_session_maker),
@@ -281,7 +281,7 @@ async def test_success_callback_stripe_incomplete(
):
"""Test success callback when Stripe session is not complete."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
session_id = 'test_incomplete_session'
async with async_session_maker() as session:
@@ -319,7 +319,7 @@ async def test_success_callback_stripe_incomplete(
async def test_success_callback_success(async_session_maker, test_org, test_user):
"""Test successful payment completion and credit update."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
session_id = 'test_success_session'
async with async_session_maker() as session:
@@ -391,7 +391,7 @@ async def test_success_callback_lite_llm_error(
):
"""Test handling of LiteLLM API errors during success callback."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
session_id = 'test_litellm_error_session'
async with async_session_maker() as session:
@@ -445,7 +445,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
the database transaction rolls back.
"""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
session_id = 'test_budget_rollback_session'
async with async_session_maker() as session:
@@ -502,7 +502,7 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(
async def test_cancel_callback_session_not_found(async_session_maker):
"""Test cancel callback when billing session is not found."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
with patch('server.routes.billing.a_session_maker', async_session_maker):
response = await cancel_callback('nonexistent_session_id', mock_request)
@@ -517,7 +517,7 @@ async def test_cancel_callback_session_not_found(async_session_maker):
async def test_cancel_callback_success(async_session_maker, test_org, test_user):
"""Test successful cancellation of billing session."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
session_id = 'test_cancel_session'
async with async_session_maker() as session:
@@ -588,7 +588,7 @@ async def test_create_customer_setup_session_success():
'headers': [],
}
)
mock_request._base_url = URL('http://test.com/')
mock_request._url = URL('http://test.com/')
mock_customer_info = {'customer_id': 'mock-customer-id', 'org_id': 'mock-org-id'}
mock_session = MagicMock()
@@ -613,6 +613,6 @@ async def test_create_customer_setup_session_success():
customer='mock-customer-id',
mode='setup',
payment_method_types=['card'],
success_url='https://test.com/?setup=success',
cancel_url='https://test.com/',
success_url='https://test.com?setup=success',
cancel_url='https://test.com',
)

View File

@@ -98,6 +98,11 @@ class TestAcceptInvitationEmailValidation:
mock_keycloak_user_info = {'email': 'alice@example.com'} # Email from Keycloak
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -121,6 +126,10 @@ class TestAcceptInvitationEmailValidation:
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
@@ -145,6 +154,7 @@ class TestAcceptInvitationEmailValidation:
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act - should not raise error because Keycloak email matches
@@ -214,6 +224,11 @@ class TestAcceptInvitationEmailValidation:
mock_invitation.email = 'alice@example.com' # Lowercase in invitation
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
@@ -234,6 +249,10 @@ class TestAcceptInvitationEmailValidation:
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
@@ -250,6 +269,7 @@ class TestAcceptInvitationEmailValidation:
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act - should not raise error because emails match case-insensitively
@@ -258,6 +278,75 @@ class TestAcceptInvitationEmailValidation:
# Assert - invitation was accepted (update_invitation_status was called)
mock_update_status.assert_called_once()
@pytest.mark.asyncio
async def test_accept_invitation_inherits_org_llm_settings(self, mock_invitation):
"""Test that new members inherit the organization's LLM settings when accepting invitation."""
# Arrange
user_id = UUID('87654321-4321-8765-4321-876543218765')
token = 'inv-test-token-12345'
mock_user = MagicMock()
mock_user.id = user_id
mock_user.email = 'alice@example.com'
mock_org = MagicMock()
mock_org.default_llm_model = 'claude-sonnet-4'
mock_org.default_llm_base_url = 'https://api.anthropic.com'
mock_org.default_max_iterations = 100
with (
patch(
'server.services.org_invitation_service.OrgInvitationStore.get_invitation_by_token',
new_callable=AsyncMock,
) as mock_get_invitation,
patch(
'server.services.org_invitation_service.OrgInvitationStore.is_token_expired'
) as mock_is_expired,
patch(
'server.services.org_invitation_service.UserStore.get_user_by_id',
new_callable=AsyncMock,
) as mock_get_user,
patch(
'server.services.org_invitation_service.OrgMemberStore.get_org_member',
new_callable=AsyncMock,
) as mock_get_member,
patch(
'server.services.org_invitation_service.OrgService.create_litellm_integration',
new_callable=AsyncMock,
) as mock_create_litellm,
patch(
'server.services.org_invitation_service.OrgStore.get_org_by_id',
new_callable=AsyncMock,
) as mock_get_org,
patch(
'server.services.org_invitation_service.OrgMemberStore.add_user_to_org',
new_callable=AsyncMock,
) as mock_add_user,
patch(
'server.services.org_invitation_service.OrgInvitationStore.update_invitation_status',
new_callable=AsyncMock,
) as mock_update_status,
):
mock_get_invitation.return_value = mock_invitation
mock_is_expired.return_value = False
mock_get_user.return_value = mock_user
mock_get_member.return_value = None
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
# Act
await OrgInvitationService.accept_invitation(token, user_id)
# Assert - verify add_user_to_org was called with org's LLM settings
mock_add_user.assert_called_once()
call_kwargs = mock_add_user.call_args.kwargs
assert call_kwargs['llm_model'] == 'claude-sonnet-4'
assert call_kwargs['llm_base_url'] == 'https://api.anthropic.com'
assert call_kwargs['max_iterations'] == 100
class TestCreateInvitationsBatch:
"""Test cases for batch invitation creation."""

View File

@@ -246,6 +246,43 @@ async def test_add_user_to_org(async_session_maker):
assert org_member.status == 'active'
@pytest.mark.asyncio
async def test_add_user_to_org_with_llm_settings(async_session_maker):
"""Test that add_user_to_org correctly sets inherited LLM settings from organization."""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org-llm')
session.add(org)
await session.flush()
user = User(id=uuid.uuid4(), current_org_id=org.id)
role = Role(name='member', rank=2)
session.add_all([user, role])
await session.commit()
org_id = org.id
user_id = user.id
role_id = role.id
# Act
with patch('storage.org_member_store.a_session_maker', async_session_maker):
org_member = await OrgMemberStore.add_user_to_org(
org_id=org_id,
user_id=user_id,
role_id=role_id,
llm_api_key='test-api-key',
status='active',
llm_model='claude-sonnet-4',
llm_base_url='https://api.example.com',
max_iterations=50,
)
# Assert
assert org_member is not None
assert org_member.llm_model == 'claude-sonnet-4'
assert org_member.llm_base_url == 'https://api.example.com'
assert org_member.max_iterations == 50
@pytest.mark.asyncio
async def test_update_user_role_in_org(async_session_maker):
# Test updating user role in org

View File

@@ -396,3 +396,44 @@ async def test_store_propagates_llm_settings_to_all_org_members(
assert (
decrypted_key == 'new-shared-api-key'
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
@pytest.mark.asyncio
async def test_store_updates_org_default_llm_settings(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When admin saves LLM settings, org's default_llm_model/base_url/max_iterations should be updated.
This test verifies that the Org table's default settings are updated so that
new members joining later will inherit the correct LLM configuration.
"""
from sqlalchemy import select
from storage.org import Org
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=75,
llm_api_key=SecretStr('test-api-key'),
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - verify org's default fields were updated
with session_maker() as session:
result = session.execute(select(Org).where(Org.id == org_id))
org = result.scalars().first()
assert org is not None
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
assert org.default_max_iterations == 75

View File

@@ -0,0 +1 @@
# Tests for enterprise server utils

View File

@@ -0,0 +1,425 @@
"""Tests for URL utility functions that prevent URL hijacking attacks."""
from unittest.mock import MagicMock, patch
import pytest
class TestGetWebUrl:
"""Tests for get_web_url function."""
@pytest.fixture
def mock_request(self):
"""Create a mock FastAPI request object."""
request = MagicMock()
request.url = MagicMock()
return request
def test_configured_web_url_is_used(self, mock_request):
"""When web_url is configured, it should be used instead of request URL."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'evil-attacker.com'
mock_request.url.netloc = 'evil-attacker.com:443'
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://app.all-hands.dev'
# Should not use any info from the potentially poisoned request
assert 'evil-attacker.com' not in result
def test_configured_web_url_trailing_slash_stripped(self, mock_request):
"""Configured web_url should have trailing slashes stripped."""
from server.utils.url_utils import get_web_url
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev/'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://app.all-hands.dev'
assert not result.endswith('/')
def test_unconfigured_web_url_localhost_uses_http(self, mock_request):
"""When web_url is not configured and hostname is localhost, use http."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'localhost'
mock_request.url.netloc = 'localhost:3000'
mock_config = MagicMock()
mock_config.web_url = None
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'http://localhost:3000'
def test_unconfigured_web_url_non_localhost_uses_https(self, mock_request):
"""When web_url is not configured and hostname is not localhost, use https."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'example.com'
mock_request.url.netloc = 'example.com:443'
mock_config = MagicMock()
mock_config.web_url = None
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'https://example.com:443'
def test_unconfigured_web_url_empty_string_fallback(self, mock_request):
"""Empty string web_url should trigger fallback."""
from server.utils.url_utils import get_web_url
mock_request.url.hostname = 'localhost'
mock_request.url.netloc = 'localhost:3000'
mock_config = MagicMock()
mock_config.web_url = ''
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
assert result == 'http://localhost:3000'
class TestGetCookieDomain:
"""Tests for get_cookie_domain function."""
def test_production_with_configured_web_url(self):
"""In production with web_url configured, should return hostname."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result == 'app.all-hands.dev'
def test_production_without_web_url_returns_none(self):
"""In production without web_url configured, should return None."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = None
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
def test_local_env_returns_none(self):
"""In local environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
):
result = get_cookie_domain()
assert result is None
def test_staging_env_returns_none(self):
"""In staging environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
def test_feature_env_returns_none(self):
"""In feature environment, should return None for cookie domain."""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://feature-123.staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_domain()
assert result is None
class TestGetCookieSamesite:
"""Tests for get_cookie_samesite function."""
def test_production_with_configured_web_url_returns_strict(self):
"""In production with web_url configured, should return 'strict'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'strict'
def test_production_without_web_url_returns_lax(self):
"""In production without web_url configured, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = None
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_local_env_returns_lax(self):
"""In local environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'http://localhost:3000'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', True),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_staging_env_returns_lax(self):
"""In staging environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_feature_env_returns_lax(self):
"""In feature environment, should return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://feature-xyz.staging.all-hands.dev'
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', True),
patch('server.utils.url_utils.IS_STAGING_ENV', True),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
def test_empty_web_url_returns_lax(self):
"""Empty web_url should be treated as unconfigured and return 'lax'."""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = ''
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
result = get_cookie_samesite()
assert result == 'lax'
class TestSecurityScenarios:
"""Tests for security-critical scenarios."""
@pytest.fixture
def mock_request(self):
"""Create a mock FastAPI request object."""
request = MagicMock()
request.url = MagicMock()
return request
def test_header_poisoning_attack_blocked_when_configured(self, mock_request):
"""
When web_url is configured, X-Forwarded-* header poisoning should not affect
the returned URL.
"""
from server.utils.url_utils import get_web_url
# Simulate a poisoned request where attacker controls headers
mock_request.url.hostname = 'evil.com'
mock_request.url.netloc = 'evil.com:443'
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
with patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
):
result = get_web_url(mock_request)
# Should use configured web_url, not the poisoned request data
assert result == 'https://app.all-hands.dev'
assert 'evil' not in result
def test_cookie_domain_not_set_in_dev_environments(self):
"""
Cookie domain should not be set in development environments to prevent
cookies from leaking to other subdomains.
"""
from server.utils.url_utils import get_cookie_domain
mock_config = MagicMock()
mock_config.web_url = 'https://my-feature.staging.all-hands.dev'
# Test each dev environment
for env_name, env_config in [
(
'local',
{
'IS_LOCAL_ENV': True,
'IS_STAGING_ENV': False,
'IS_FEATURE_ENV': False,
},
),
(
'staging',
{
'IS_LOCAL_ENV': False,
'IS_STAGING_ENV': True,
'IS_FEATURE_ENV': False,
},
),
(
'feature',
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
),
]:
with (
patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
),
patch(
'server.utils.url_utils.IS_FEATURE_ENV',
env_config['IS_FEATURE_ENV'],
),
patch(
'server.utils.url_utils.IS_STAGING_ENV',
env_config['IS_STAGING_ENV'],
),
patch(
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
),
):
result = get_cookie_domain()
assert result is None, f'Expected None for {env_name} environment'
def test_strict_samesite_only_in_production(self):
"""
SameSite=strict should only be set in production to ensure proper
security without breaking OAuth flows in development.
"""
from server.utils.url_utils import get_cookie_samesite
mock_config = MagicMock()
mock_config.web_url = 'https://app.all-hands.dev'
# Production should be strict
with (
patch('server.utils.url_utils.get_global_config', return_value=mock_config),
patch('server.utils.url_utils.IS_FEATURE_ENV', False),
patch('server.utils.url_utils.IS_STAGING_ENV', False),
patch('server.utils.url_utils.IS_LOCAL_ENV', False),
):
assert get_cookie_samesite() == 'strict'
# Dev environments should be lax
for env_config in [
{'IS_LOCAL_ENV': True, 'IS_STAGING_ENV': False, 'IS_FEATURE_ENV': False},
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': False},
{'IS_LOCAL_ENV': False, 'IS_STAGING_ENV': True, 'IS_FEATURE_ENV': True},
]:
with (
patch(
'server.utils.url_utils.get_global_config', return_value=mock_config
),
patch(
'server.utils.url_utils.IS_FEATURE_ENV',
env_config['IS_FEATURE_ENV'],
),
patch(
'server.utils.url_utils.IS_STAGING_ENV',
env_config['IS_STAGING_ENV'],
),
patch(
'server.utils.url_utils.IS_LOCAL_ENV', env_config['IS_LOCAL_ENV']
),
):
assert get_cookie_samesite() == 'lax'

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"dependencies": {
"@heroui/react": "2.8.7",
"@microlink/react-json-view": "^1.27.1",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -49,6 +49,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
get_project_dir,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
@@ -540,10 +541,13 @@ async def get_conversation_skills(
# Prefer the shared loader to avoid duplication; otherwise return empty list.
all_skills: list = []
if isinstance(app_conversation_service, AppConversationServiceBase):
project_dir = get_project_dir(
sandbox_spec.working_dir, conversation.selected_repository
)
all_skills = await app_conversation_service.load_and_merge_all_skills(
sandbox,
conversation.selected_repository,
sandbox_spec.working_dir,
project_dir,
agent_server_url,
)

View File

@@ -47,6 +47,40 @@ PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
def get_project_dir(
working_dir: str,
selected_repository: str | None = None,
) -> str:
"""Get the project root directory for a conversation.
When a repository is selected, the project root is the cloned repo directory
at {working_dir}/{repo_name}. This is the directory that contains the
`.openhands/` configuration (setup.sh, pre-commit.sh, skills/, etc.).
Without a repository, the project root is the working_dir itself.
This must be used consistently for ALL features that depend on the project root:
- workspace.working_dir (terminal CWD, file editor root, etc.)
- .openhands/setup.sh execution
- .openhands/pre-commit.sh (git hooks setup)
- .openhands/skills/ (project skills)
- PLAN.md path
Args:
working_dir: Base working directory path in the sandbox
(e.g., '/workspace/project' from sandbox_spec)
selected_repository: Repository name (e.g., 'OpenHands/software-agent-sdk')
If provided, the repo name is appended to working_dir.
Returns:
The project root directory path.
"""
if selected_repository:
repo_name = selected_repository.split('/')[-1]
return f'{working_dir}/{repo_name}'
return working_dir
@dataclass
class AppConversationServiceBase(AppConversationService, ABC):
"""App Conversation service which adds git specific functionality.
@@ -61,7 +95,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
self,
sandbox: SandboxInfo,
selected_repository: str | None,
working_dir: str,
project_dir: str,
agent_server_url: str,
) -> list[Skill]:
"""Load skills from all sources via the agent-server.
@@ -77,7 +111,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Args:
sandbox: SandboxInfo containing exposed URLs and agent-server URL
selected_repository: Repository name or None
working_dir: Working directory path
project_dir: Project root directory (resolved via get_project_dir).
agent_server_url: Agent-server URL (required)
Returns:
@@ -96,12 +130,6 @@ class AppConversationServiceBase(AppConversationService, ABC):
# Build sandbox config (exposed URLs)
sandbox_config = build_sandbox_config(sandbox)
# Determine project directory for project skills
project_dir = working_dir
if selected_repository:
repo_name = selected_repository.split('/')[-1]
project_dir = f'{working_dir}/{repo_name}'
# Single API call to agent-server for ALL skills
all_skills = await load_skills_from_agent_server(
agent_server_url=agent_server_url,
@@ -180,24 +208,25 @@ class AppConversationServiceBase(AppConversationService, ABC):
agent: Agent,
remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
project_dir: str,
):
"""Load all skills and update agent with them.
Args:
agent: The agent to update
remote_workspace: AsyncRemoteWorkspace for loading repo skills
selected_repository: Repository name or None
working_dir: Working directory path
selected_repository: Repository name or None (used for org config)
project_dir: Project root directory (already resolved via get_project_dir).
Returns:
Updated agent with skills loaded into context
"""
# Load and merge all skills
# Extract agent_server_url from remote_workspace host
agent_server_url = remote_workspace.host
all_skills = await self.load_and_merge_all_skills(
sandbox, selected_repository, working_dir, agent_server_url
sandbox,
selected_repository,
project_dir,
agent_server_url,
)
# Update agent with skills
@@ -216,20 +245,27 @@ class AppConversationServiceBase(AppConversationService, ABC):
yield task
await self.clone_or_init_git_repo(task, workspace)
# Compute the project root — the cloned repo directory when a repo is
# selected, or the sandbox working_dir otherwise. This must be used
# for all .openhands/ features (setup.sh, pre-commit.sh, skills).
project_dir = get_project_dir(
workspace.working_dir, task.request.selected_repository
)
task.status = AppConversationStartTaskStatus.RUNNING_SETUP_SCRIPT
yield task
await self.maybe_run_setup_script(workspace)
await self.maybe_run_setup_script(workspace, project_dir)
task.status = AppConversationStartTaskStatus.SETTING_UP_GIT_HOOKS
yield task
await self.maybe_setup_git_hooks(workspace)
await self.maybe_setup_git_hooks(workspace, project_dir)
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
await self.load_and_merge_all_skills(
sandbox,
task.request.selected_repository,
workspace.working_dir,
project_dir,
agent_server_url,
)
@@ -334,26 +370,35 @@ class AppConversationServiceBase(AppConversationService, ABC):
async def maybe_run_setup_script(
self,
workspace: AsyncRemoteWorkspace,
project_dir: str,
):
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
setup_script = workspace.working_dir + '/.openhands/setup.sh'
"""Run .openhands/setup.sh if it exists in the project root.
Args:
workspace: Remote workspace for command execution.
project_dir: Project root directory (repo root when a repo is selected).
"""
setup_script = project_dir + '/.openhands/setup.sh'
await workspace.execute_command(
f'chmod +x {setup_script} && source {setup_script}', timeout=600
f'chmod +x {setup_script} && source {setup_script}',
cwd=project_dir,
timeout=600,
)
# TODO: Does this need to be done?
# Add the action to the event stream as an ENVIRONMENT event
# source = EventSource.ENVIRONMENT
# self.event_stream.add_event(action, source)
async def maybe_setup_git_hooks(
self,
workspace: AsyncRemoteWorkspace,
project_dir: str,
):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
"""Set up git hooks if .openhands/pre-commit.sh exists in the project root.
Args:
workspace: Remote workspace for command execution.
project_dir: Project root directory (repo root when a repo is selected).
"""
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
result = await workspace.execute_command(command, workspace.working_dir)
result = await workspace.execute_command(command, project_dir)
if result.exit_code:
return
@@ -369,9 +414,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
f'chmod +x {PRE_COMMIT_LOCAL}'
)
result = await workspace.execute_command(
command, workspace.working_dir
)
result = await workspace.execute_command(command, project_dir)
if result.exit_code != 0:
_logger.error(
f'Failed to preserve existing pre-commit hook: {result.stderr}',

View File

@@ -41,6 +41,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
get_project_dir,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
@@ -1227,7 +1228,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
5. Passing plugins to the agent server for remote plugin loading
"""
user = await self.user_context.get_user_info()
workspace = LocalWorkspace(working_dir=working_dir)
# Compute the project root — this is the repo directory when a repo is
# selected, or the sandbox working_dir otherwise. All tools, hooks,
# setup scripts, and plan paths must use this consistently.
project_dir = get_project_dir(working_dir, selected_repository)
workspace = LocalWorkspace(working_dir=project_dir)
# Set up secrets for all git providers
secrets = await self._setup_secrets_for_git_providers(user)
@@ -1244,7 +1250,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
user.condenser_max_size,
secrets=secrets,
git_provider=git_provider,
working_dir=working_dir,
working_dir=project_dir,
)
# Finalize and return the conversation request
@@ -1258,7 +1264,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
sandbox,
remote_workspace,
selected_repository,
working_dir,
project_dir,
plugins=plugins,
)

View File

@@ -106,14 +106,15 @@ class EventServiceBase(EventService, ABC):
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
)
# Apply pagination to items (not paths)
start_offset = 0
next_page_id = None
if page_id:
start_offset = int(page_id)
paths = paths[start_offset:]
if len(paths) > limit:
paths = paths[:limit]
items = items[start_offset:]
if len(items) > limit:
next_page_id = str(start_offset + limit)
items = items[:limit]
return EventPage(items=items, next_page_id=next_page_id)

View File

@@ -6,7 +6,7 @@ import logging
import pkgutil
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
from pydantic import SecretStr
@@ -23,61 +23,87 @@ from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_event_service,
depends_jwt_service,
depends_sandbox_service,
get_event_callback_service,
get_global_config,
get_sandbox_service,
)
from openhands.app_server.errors import AuthError
from openhands.app_server.event.event_service import EventService
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
SpecifyUserContext,
as_admin,
)
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import ConversationExecutionStatus, Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.server.types import AppMode
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
sandbox_service_dependency = depends_sandbox_service()
event_service_dependency = depends_event_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
jwt_dependency = depends_jwt_service()
app_mode = get_global_config().app_mode
_logger = logging.getLogger(__name__)
async def valid_sandbox(
user_context: UserContext = Depends(as_admin),
request: Request,
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
"""Use a session api key for validation, and get a sandbox. Subsequent actions
are executed in the context of the owner of the sandbox"""
if not session_api_key:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
)
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
# Create a state which will be used internally only for this operation
state = InjectorState()
# Since we need access to all sandboxes, this is executed in the context of the admin.
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with get_sandbox_service(state) as sandbox_service:
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
return sandbox_info
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
)
# In SAAS Mode there is always a user, so we set the owner of the sandbox
# as the current user (Validated by the session_api_key they provided)
if sandbox_info.created_by_user_id:
setattr(
request.state,
USER_CONTEXT_ATTR,
SpecifyUserContext(sandbox_info.created_by_user_id),
)
elif app_mode == AppMode.SAAS:
_logger.error(
'Sandbox had no user specified', extra={'sandbox_id': sandbox_info.id}
)
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Sandbox had no user specified'
)
return sandbox_info
async def valid_conversation(
conversation_id: UUID,
sandbox_info: SandboxInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
) -> AppConversationInfo:
app_conversation_info = (
@@ -90,9 +116,11 @@ async def valid_conversation(
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
# Sanity check - Make sure that the conversation and sandbox were created by the same user
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
# Make sure that the conversation and sandbox were created by the same user
raise AuthError()
return app_conversation_info
@@ -139,15 +167,11 @@ async def on_conversation_update(
async def on_event(
events: list[Event],
conversation_id: UUID,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info: AppConversationInfo = Depends(valid_conversation),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
event_service: EventService = event_service_dependency,
) -> Success:
"""Webhook callback for when event stream events occur."""
app_conversation_info = await valid_conversation(
conversation_id, sandbox_info, app_conversation_info_service
)
try:
# Save events...
await asyncio.gather(

View File

@@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.12.0-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python'
class SandboxSpecService(ABC):

50
poetry.lock generated
View File

@@ -6367,14 +6367,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
]
[package.dependencies]
@@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
]
[package.dependencies]
@@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
]
[package.dependencies]
@@ -11577,14 +11577,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
]
[package.extras]
@@ -13579,24 +13579,22 @@ files = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main", "runtime"]
files = [
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
]
[[package]]
@@ -14848,4 +14846,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9"

View File

@@ -57,9 +57,9 @@ dependencies = [
"numpy",
"openai==2.8",
"openhands-aci==0.3.3",
"openhands-agent-server==1.12",
"openhands-sdk==1.12",
"openhands-tools==1.12",
"openhands-agent-server==1.13",
"openhands-sdk==1.13",
"openhands-tools==1.13",
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
"pathspec>=0.12.1",
@@ -144,7 +144,7 @@ runtime = [
[tool.poetry]
name = "openhands-ai"
version = "1.4.0"
version = "1.5.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -249,9 +249,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-sdk = "1.12"
openhands-agent-server = "1.12"
openhands-tools = "1.12"
openhands-sdk = "1.13"
openhands-agent-server = "1.13"
openhands-tools = "1.13"
jwcrypto = ">=1.5.6"
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"

View File

@@ -1035,7 +1035,7 @@ class TestLoadAndMergeAllSkills:
# Act
result = await service.load_and_merge_all_skills(
sandbox, 'owner/repo', '/workspace', 'http://localhost:8000'
sandbox, 'owner/repo', '/workspace/repo', 'http://localhost:8000'
)
# Assert
@@ -1073,7 +1073,7 @@ class TestLoadAndMergeAllSkills:
# Act - pass empty string to simulate no agent server URL
# This should still call load_skills_from_agent_server but it will fail
result = await service.load_and_merge_all_skills(
sandbox, 'owner/repo', '/workspace', ''
sandbox, 'owner/repo', '/workspace/repo', ''
)
# Assert - should return empty list when agent_server_url is empty
@@ -1089,13 +1089,13 @@ class TestLoadAndMergeAllSkills:
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.build_sandbox_config'
)
async def test_uses_working_dir_when_no_repository(
async def test_uses_project_dir_when_no_repository(
self,
mock_build_sandbox_config,
mock_build_org_config,
mock_load_skills,
):
"""Test uses working_dir as project_dir when no repository is selected."""
"""Test uses project_dir directly when no repository is selected."""
# Arrange
mock_user_context = Mock(spec=UserContext)
with patch.object(AppConversationServiceBase, '__abstractmethods__', set()):
@@ -1164,7 +1164,7 @@ class TestLoadAndMergeAllSkills:
# Act
result = await service.load_and_merge_all_skills(
sandbox, 'owner/repo', '/workspace', 'http://localhost:8000'
sandbox, 'owner/repo', '/workspace/repo', 'http://localhost:8000'
)
# Assert

View File

@@ -161,6 +161,113 @@ class TestFilesystemEventServiceSearchEvents:
assert hasattr(result, 'next_page_id')
assert len(result.items) == 3
@pytest.mark.asyncio
async def test_search_events_pagination_limits_results(
self, service: FilesystemEventService
):
"""Test that search_events respects the limit parameter for pagination."""
conversation_id = uuid4()
total_events = 10
page_limit = 3
# Create more events than the limit
for _ in range(total_events):
await service.save_event(conversation_id, create_token_event())
# First page should return only 'limit' events
result = await service.search_events(conversation_id, limit=page_limit)
assert len(result.items) == page_limit
assert result.next_page_id is not None
@pytest.mark.asyncio
async def test_search_events_pagination_iterates_all_events(
self, service: FilesystemEventService
):
"""Test that pagination correctly iterates through all events without duplicates.
This test verifies the fix for a bug where pagination was applied to 'paths'
instead of 'items', causing all events to be returned on every page.
"""
conversation_id = uuid4()
total_events = 10
page_limit = 3
# Create events and track their IDs
created_event_ids = set()
for _ in range(total_events):
event = create_token_event()
created_event_ids.add(event.id)
await service.save_event(conversation_id, event)
# Iterate through all pages and collect event IDs
collected_event_ids = set()
page_id = None
page_count = 0
while True:
result = await service.search_events(
conversation_id, page_id=page_id, limit=page_limit
)
page_count += 1
for item in result.items:
# Verify no duplicates - this would fail with the old buggy code
assert item.id not in collected_event_ids, (
f'Duplicate event {item.id} found on page {page_count}'
)
collected_event_ids.add(item.id)
if result.next_page_id is None:
break
page_id = result.next_page_id
# Verify we got all events exactly once
assert collected_event_ids == created_event_ids
assert len(collected_event_ids) == total_events
# With 10 events and limit of 3, we should have 4 pages (3+3+3+1)
expected_pages = (total_events + page_limit - 1) // page_limit
assert page_count == expected_pages
@pytest.mark.asyncio
async def test_search_events_pagination_with_filters(
self, service: FilesystemEventService
):
"""Test that pagination works correctly when combined with filters."""
conversation_id = uuid4()
# Create a mix of events
token_events = [create_token_event() for _ in range(5)]
pause_events = [create_pause_event() for _ in range(3)]
for event in token_events + pause_events:
await service.save_event(conversation_id, event)
# Search only for token events with pagination
page_limit = 2
collected_ids = set()
page_id = None
while True:
result = await service.search_events(
conversation_id,
kind__eq='TokenEvent',
page_id=page_id,
limit=page_limit,
)
for item in result.items:
assert item.kind == 'TokenEvent'
collected_ids.add(item.id)
if result.next_page_id is None:
break
page_id = result.next_page_id
# Should have found all 5 token events
assert len(collected_ids) == 5
class TestFilesystemEventServiceIntegration:
"""Integration tests for FilesystemEventService."""

View File

@@ -1199,6 +1199,9 @@ class TestLiveStatusAppConversationService:
self.service._configure_llm_and_mcp.assert_called_once_with(
self.mock_user, 'gpt-4'
)
# When selected_repository='test/repo', project_dir is resolved
# to '/test/dir/repo' via get_project_dir. All downstream calls
# (agent context, workspace, skills) must use this path.
self.service._create_agent_with_context.assert_called_once_with(
mock_llm,
AgentType.DEFAULT,
@@ -1207,7 +1210,7 @@ class TestLiveStatusAppConversationService:
self.mock_user.condenser_max_size,
secrets=mock_secrets,
git_provider=ProviderType.GITHUB,
working_dir='/test/dir',
working_dir='/test/dir/repo',
)
self.service._finalize_conversation_request.assert_called_once()
@@ -1989,6 +1992,111 @@ class TestLiveStatusAppConversationService:
assert stdio_server['command'] == 'npx'
assert stdio_server['env'] == {'TOKEN': 'value'}
# ------------------------------------------------------------------ #
# Regression tests: workspace.working_dir == project_dir #
# ------------------------------------------------------------------ #
def test_get_project_dir_with_repo(self):
"""get_project_dir appends repo name to working_dir."""
from openhands.app_server.app_conversation.app_conversation_service_base import (
get_project_dir,
)
assert (
get_project_dir('/workspace/project', 'OpenHands/software-agent-sdk')
== '/workspace/project/software-agent-sdk'
)
assert get_project_dir('/w', 'org/repo-name') == '/w/repo-name'
def test_get_project_dir_without_repo(self):
"""get_project_dir returns working_dir unchanged when no repo selected."""
from openhands.app_server.app_conversation.app_conversation_service_base import (
get_project_dir,
)
assert get_project_dir('/workspace/project', None) == '/workspace/project'
assert get_project_dir('/workspace/project', '') == '/workspace/project'
@pytest.mark.asyncio
async def test_build_request_workspace_uses_project_dir(self):
"""workspace.working_dir in StartConversationRequest must equal project_dir.
This is the root cause of the V1 hook-stop bug: if workspace.working_dir
points to the sandbox mount root (/workspace/project) instead of the
cloned repo (/workspace/project/<repo>), the agent's CWD is wrong and
.openhands/hooks/on_stop.sh is not found.
"""
self.mock_user_context.get_user_info.return_value = self.mock_user
mock_secrets = {'GITHUB_TOKEN': Mock()}
mock_llm = Mock(spec=LLM)
mock_agent = Mock(spec=Agent)
self.service._setup_secrets_for_git_providers = AsyncMock(
return_value=mock_secrets
)
self.service._configure_llm_and_mcp = AsyncMock(return_value=(mock_llm, {}))
self.service._create_agent_with_context = Mock(return_value=mock_agent)
captured = {}
async def capture_finalize(
agent, conversation_id, user, workspace, *args, **kwargs
):
captured['workspace_working_dir'] = workspace.working_dir
return Mock(spec=StartConversationRequest)
self.service._finalize_conversation_request = AsyncMock(
side_effect=capture_finalize
)
await self.service._build_start_conversation_request_for_user(
sandbox=self.mock_sandbox,
initial_message=None,
system_message_suffix=None,
git_provider=None,
working_dir='/workspace/project',
selected_repository='OpenHands/software-agent-sdk',
)
assert (
captured['workspace_working_dir'] == '/workspace/project/software-agent-sdk'
), 'workspace.working_dir must point to the repo root, not the sandbox mount'
@pytest.mark.asyncio
async def test_build_request_no_repo_workspace_unchanged(self):
"""Without selected_repository, workspace.working_dir == sandbox working_dir."""
self.mock_user_context.get_user_info.return_value = self.mock_user
self.service._setup_secrets_for_git_providers = AsyncMock(return_value={})
self.service._configure_llm_and_mcp = AsyncMock(
return_value=(Mock(spec=LLM), {})
)
self.service._create_agent_with_context = Mock(return_value=Mock(spec=Agent))
captured = {}
async def capture_finalize(
agent, conversation_id, user, workspace, *args, **kwargs
):
captured['workspace_working_dir'] = workspace.working_dir
return Mock(spec=StartConversationRequest)
self.service._finalize_conversation_request = AsyncMock(
side_effect=capture_finalize
)
await self.service._build_start_conversation_request_for_user(
sandbox=self.mock_sandbox,
initial_message=None,
system_message_suffix=None,
git_provider=None,
working_dir='/workspace/project',
selected_repository=None,
)
assert captured['workspace_working_dir'] == '/workspace/project'
class TestPluginHandling:
"""Test cases for plugin-related functionality in LiveStatusAppConversationService."""

View File

@@ -3,18 +3,65 @@
This module tests the webhook authentication and authorization logic.
"""
from unittest.mock import AsyncMock, MagicMock
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import HTTPException, status
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from openhands.app_server.event_callback.webhook_router import (
router as webhook_router,
)
from openhands.app_server.event_callback.webhook_router import (
valid_conversation,
valid_sandbox,
)
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
from openhands.app_server.user.specifiy_user_context import ADMIN
from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
)
from openhands.server.types import AppMode
class MockRequestState:
"""A mock request state that tracks attribute assignments."""
def __init__(self):
self._state = {}
self._attributes = {}
def __setattr__(self, name, value):
if name.startswith('_'):
super().__setattr__(name, value)
else:
self._attributes[name] = value
def __getattr__(self, name):
if name in self._attributes:
return self._attributes[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
def create_mock_request():
"""Create a mock FastAPI Request object with proper state."""
request = MagicMock()
request.state = MockRequestState()
return request
def create_sandbox_service_context_manager(sandbox_service):
"""Create an async context manager that yields the given sandbox service."""
@contextlib.asynccontextmanager
async def _context_manager(state, request=None):
yield sandbox_service
return _context_manager
class TestValidSandbox:
@@ -22,14 +69,15 @@ class TestValidSandbox:
@pytest.mark.asyncio
async def test_valid_sandbox_with_valid_api_key(self):
"""Test that valid API key returns sandbox info."""
"""Test that valid API key returns sandbox info and sets user_context."""
# Arrange
session_api_key = 'valid-api-key-123'
user_id = 'user-123'
expected_sandbox = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id='user-123',
created_by_user_id=user_id,
sandbox_spec_id='spec-123',
)
@@ -38,12 +86,17 @@ class TestValidSandbox:
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert
assert result == expected_sandbox
@@ -51,18 +104,136 @@ class TestValidSandbox:
session_api_key
)
# Verify user_context is set correctly on request.state
assert USER_CONTEXT_ATTR in mock_request.state._attributes
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
assert isinstance(user_context, SpecifyUserContext)
assert user_context.user_id == user_id
@pytest.mark.asyncio
async def test_valid_sandbox_sets_user_context_to_sandbox_owner(self):
"""Test that user_context is set to the sandbox owner's user ID."""
# Arrange
session_api_key = 'valid-api-key'
sandbox_owner_id = 'sandbox-owner-user-id'
expected_sandbox = SandboxInfo(
id='sandbox-456',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=sandbox_owner_id,
sandbox_spec_id='spec-456',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert - user_context should be set to the sandbox owner
assert USER_CONTEXT_ATTR in mock_request.state._attributes
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
assert isinstance(user_context, SpecifyUserContext)
assert user_context.user_id == sandbox_owner_id
@pytest.mark.asyncio
async def test_valid_sandbox_no_user_context_when_no_user_id(self):
"""Test that user_context is not set when sandbox has no created_by_user_id."""
# Arrange
session_api_key = 'valid-api-key'
expected_sandbox = SandboxInfo(
id='sandbox-789',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=None, # No user ID
sandbox_spec_id='spec-789',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Assert - sandbox is returned but user_context should NOT be set
assert result == expected_sandbox
# Verify user_context is NOT set on request.state
assert USER_CONTEXT_ATTR not in mock_request.state._attributes
@pytest.mark.asyncio
async def test_valid_sandbox_no_user_context_when_no_user_id_raises_401_in_saas_mode(
self,
):
"""Test that user_context is not set when sandbox has no created_by_user_id."""
# Arrange
session_api_key = 'valid-api-key'
expected_sandbox = SandboxInfo(
id='sandbox-789',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id=None, # No user ID
sandbox_spec_id='spec-789',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
mock_request = create_mock_request()
# Act
with (
patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
),
patch(
'openhands.app_server.event_callback.webhook_router.app_mode',
AppMode.SAAS,
),
):
with pytest.raises(HTTPException) as excinfo:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert excinfo.value.status_code == 401
@pytest.mark.asyncio
async def test_valid_sandbox_without_api_key_raises_401(self):
"""Test that missing API key raises 401 error."""
# Arrange
mock_sandbox_service = AsyncMock()
mock_request = create_mock_request()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
request=mock_request,
session_api_key=None,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -78,13 +249,18 @@ class TestValidSandbox:
return_value=None
)
mock_request = create_mock_request()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert 'Invalid session API key' in exc_info.value.detail
@@ -95,13 +271,13 @@ class TestValidSandbox:
# Arrange - empty string is falsy, so it gets rejected at the check
session_api_key = ''
mock_sandbox_service = AsyncMock()
mock_request = create_mock_request()
# Act & Assert - should raise 401 because empty string fails the truth check
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
request=mock_request,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -263,12 +439,17 @@ class TestWebhookAuthenticationIntegration:
return_value=conversation_info
)
mock_request = create_mock_request()
# Act - Call valid_sandbox first
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
sandbox_result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# Then call valid_conversation
conversation_result = await valid_conversation(
@@ -291,13 +472,18 @@ class TestWebhookAuthenticationIntegration:
return_value=None
)
mock_request = create_mock_request()
# Act & Assert - Should fail at valid_sandbox
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@@ -328,12 +514,17 @@ class TestWebhookAuthenticationIntegration:
return_value=different_user_info
)
mock_request = create_mock_request()
# Act - valid_sandbox succeeds
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
with patch(
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
create_sandbox_service_context_manager(mock_sandbox_service),
):
sandbox_result = await valid_sandbox(
request=mock_request,
session_api_key=session_api_key,
)
# But valid_conversation fails
from openhands.app_server.errors import AuthError
@@ -344,3 +535,88 @@ class TestWebhookAuthenticationIntegration:
sandbox_info=sandbox_result,
app_conversation_info_service=mock_conversation_service,
)
class TestWebhookRouterHTTPIntegration:
"""Integration tests for webhook router HTTP layer.
These tests validate that FastAPI routing correctly extracts conversation_id
from the request body rather than requiring it as a query parameter.
"""
def test_conversation_update_endpoint_does_not_require_query_param(self):
"""Test that /webhooks/conversations endpoint accepts conversation_id in body only.
This test validates the fix for the regression where the endpoint incorrectly
required conversation_id as a query parameter due to using Depends(valid_conversation).
The endpoint should:
1. Accept POST requests without any query parameters
2. Extract conversation_id from the request body (conversation_info.id)
3. Return 401 (not 422) when auth fails, proving the request was parsed correctly
"""
# Create a minimal FastAPI app with just the webhook router
app = FastAPI()
app.include_router(webhook_router, prefix='/api/v1')
client = TestClient(app, raise_server_exceptions=False)
# Create a valid request body with conversation_id in it
conversation_id = str(uuid4())
request_body = {
'id': conversation_id,
'execution_status': 'running',
'agent': {
'llm': {
'model': 'gpt-4',
},
},
'stats': {
'usage_to_metrics': {},
},
}
# POST to /webhooks/conversations WITHOUT any query parameters
# If the old bug existed (conversation_id required as query param),
# FastAPI would return 422 Unprocessable Entity
response = client.post(
'/api/v1/webhooks/conversations',
json=request_body,
# No X-Session-API-Key header - should fail auth but NOT validation
)
# We expect 401 Unauthorized (missing session API key)
# NOT 422 Unprocessable Entity (which would indicate conversation_id
# was incorrectly required as a query parameter)
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
f'Expected 401 (auth failure), got {response.status_code}. '
f'If 422, the endpoint incorrectly requires conversation_id as query param. '
f'Response: {response.json()}'
)
assert response.json()['detail'] == 'X-Session-API-Key header is required'
def test_events_endpoint_still_requires_conversation_id_in_path(self):
"""Test that /webhooks/events/{conversation_id} correctly requires path param.
This ensures we didn't accidentally break the events endpoint which legitimately
requires conversation_id as a path parameter.
"""
# Create a minimal FastAPI app with just the webhook router
app = FastAPI()
app.include_router(webhook_router, prefix='/api/v1')
client = TestClient(app, raise_server_exceptions=False)
conversation_id = str(uuid4())
request_body = [] # Empty events list
# POST to /webhooks/events/{conversation_id} with path parameter
response = client.post(
f'/api/v1/webhooks/events/{conversation_id}',
json=request_body,
# No X-Session-API-Key header - should fail auth but NOT validation
)
# We expect 401 Unauthorized (missing session API key)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json()['detail'] == 'X-Session-API-Key header is required'

View File

@@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.event_callback.webhook_router import on_conversation_update
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
from openhands.app_server.utils.sql_utils import Base
@@ -118,9 +119,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Saved conversation retains the parent_conversation_id
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -137,12 +135,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -175,9 +172,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Saved conversation has parent_conversation_id as None
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
conversation_id = mock_conversation_info.id
@@ -191,12 +185,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=None,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -228,9 +221,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- New conversation has parent_conversation_id as None
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
conversation_id = mock_conversation_info.id
@@ -242,12 +232,11 @@ class TestOnConversationUpdateParentConversationId:
created_by_user_id=sandbox_info.created_by_user_id,
)
# Mock valid_conversation to return stub (as it would for new conversation)
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=stub_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -280,9 +269,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- All metadata including parent_conversation_id is preserved
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -302,12 +288,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -349,9 +334,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Parent_conversation_id remains unchanged after all updates
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -366,9 +348,8 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return conversation with parent
# In real scenario, this would be retrieved from DB after first save
async def mock_valid_conv(*args, **kwargs):
# Act - Update multiple times, simulating what valid_conversation would return
for _ in range(3):
# After first save, get from DB with parent preserved
saved = await app_conversation_info_service.get_app_conversation_info(
conversation_id
@@ -376,21 +357,20 @@ class TestOnConversationUpdateParentConversationId:
if saved:
# Override created_by_user_id for auth check
saved.created_by_user_id = 'user_123'
return saved
return initial_conv
existing = saved
else:
existing = initial_conv
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
side_effect=mock_valid_conv,
):
# Act - Update multiple times
for _ in range(3):
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
):
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
app_conversation_info_service=app_conversation_info_service,
)
assert isinstance(result, Success)
assert isinstance(result, Success)
# Assert
saved_conv = await app_conversation_info_service.get_app_conversation_info(
@@ -417,9 +397,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Function returns early, no updates are made
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -441,12 +418,11 @@ class TestOnConversationUpdateParentConversationId:
# Set conversation to DELETING status
mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING
# Mock valid_conversation (though it won't be called for DELETING status)
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,
@@ -481,9 +457,6 @@ class TestOnConversationUpdateParentConversationId:
Assert:
- Parent_conversation_id is preserved and title is generated
"""
from openhands.app_server.event_callback.webhook_router import (
on_conversation_update,
)
# Arrange
parent_id = uuid4()
@@ -498,12 +471,11 @@ class TestOnConversationUpdateParentConversationId:
parent_conversation_id=parent_id,
)
# Mock valid_conversation to return existing conversation
# Act - call on_conversation_update directly with mocked valid_conversation
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing_conv,
):
# Act
result = await on_conversation_update(
conversation_info=mock_conversation_info,
sandbox_info=sandbox_info,

View File

@@ -451,11 +451,9 @@ class TestOnEventStatsProcessing:
@pytest.mark.asyncio
async def test_on_event_processes_stats_events(self):
"""Test that on_event processes stats events."""
from unittest.mock import patch
from openhands.app_server.event_callback.webhook_router import on_event
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxStatus,
)
conversation_id = uuid4()
sandbox_id = 'sandbox_123'
@@ -482,15 +480,6 @@ class TestOnEventStatsProcessing:
events = [stats_event, other_event]
# Mock dependencies
mock_sandbox = SandboxInfo(
id=sandbox_id,
status=SandboxStatus.RUNNING,
session_api_key='test_key',
created_by_user_id='user_123',
sandbox_spec_id='spec_123',
)
mock_app_conversation_info = AppConversationInfo(
id=conversation_id,
sandbox_id=sandbox_id,
@@ -499,9 +488,6 @@ class TestOnEventStatsProcessing:
mock_event_service = AsyncMock()
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
# Set up process_stats_event to call update_conversation_statistics
async def process_stats_event_side_effect(event, conversation_id):
@@ -519,44 +505,33 @@ class TestOnEventStatsProcessing:
process_stats_event_side_effect
)
with (
patch(
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
return_value=mock_sandbox,
),
patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=mock_app_conversation_info,
),
patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
) as mock_callbacks,
):
with patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
) as mock_callbacks:
# Call on_event directly with dependencies
await on_event(
events=events,
conversation_id=conversation_id,
sandbox_info=mock_sandbox,
app_conversation_info=mock_app_conversation_info,
app_conversation_info_service=mock_app_conversation_info_service,
event_service=mock_event_service,
)
# Verify events were saved
assert mock_event_service.save_event.call_count == 2
# Verify events were saved
assert mock_event_service.save_event.call_count == 2
# Verify stats event was processed
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
# Verify stats event was processed
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
# Verify callbacks were scheduled
mock_callbacks.assert_called_once()
# Verify callbacks were scheduled
mock_callbacks.assert_called_once()
@pytest.mark.asyncio
async def test_on_event_skips_non_stats_events(self):
"""Test that on_event skips non-stats events."""
from unittest.mock import patch
from openhands.app_server.event_callback.webhook_router import on_event
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxStatus,
)
from openhands.events.action.message import MessageAction
conversation_id = uuid4()
@@ -568,14 +543,6 @@ class TestOnEventStatsProcessing:
MessageAction(content='test'),
]
mock_sandbox = SandboxInfo(
id=sandbox_id,
status=SandboxStatus.RUNNING,
session_api_key='test_key',
created_by_user_id='user_123',
sandbox_spec_id='spec_123',
)
mock_app_conversation_info = AppConversationInfo(
id=conversation_id,
sandbox_id=sandbox_id,
@@ -584,30 +551,18 @@ class TestOnEventStatsProcessing:
mock_event_service = AsyncMock()
mock_app_conversation_info_service = AsyncMock()
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
mock_app_conversation_info
)
with (
patch(
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
return_value=mock_sandbox,
),
patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=mock_app_conversation_info,
),
patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
),
with patch(
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
):
# Call on_event directly with dependencies
await on_event(
events=events,
conversation_id=conversation_id,
sandbox_info=mock_sandbox,
app_conversation_info=mock_app_conversation_info,
app_conversation_info_service=mock_app_conversation_info_service,
event_service=mock_event_service,
)
# Verify stats update was NOT called
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
# Verify stats update was NOT called
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()

54
uv.lock generated
View File

@@ -3642,7 +3642,7 @@ wheels = [
[[package]]
name = "openhands-agent-server"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
@@ -3656,9 +3656,9 @@ dependencies = [
{ name = "websockets" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/18/d76d977201ec93faf22d6cc979b5c9953a0b554bf3294cdb3186d48a5d5a/openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d", size = 72715, upload-time = "2026-03-05T19:22:23.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/47/dc31d7ffd6f6687ce4cc0114e01cf1f7f13f9ba841cd47dac5a983e57fb9/openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad", size = 87800, upload-time = "2026-03-05T19:22:27.857Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" },
]
[[package]]
@@ -3826,9 +3826,9 @@ requires-dist = [
{ name = "numpy" },
{ name = "openai", specifier = "==2.8" },
{ name = "openhands-aci", specifier = "==0.3.3" },
{ name = "openhands-agent-server", specifier = "==1.12" },
{ name = "openhands-sdk", specifier = "==1.12" },
{ name = "openhands-tools", specifier = "==1.12" },
{ name = "openhands-agent-server", specifier = "==1.13" },
{ name = "openhands-sdk", specifier = "==1.13" },
{ name = "openhands-tools", specifier = "==1.13" },
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
{ name = "pathspec", specifier = ">=0.12.1" },
@@ -3906,7 +3906,7 @@ test = [
[[package]]
name = "openhands-sdk"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "agent-client-protocol" },
@@ -3923,14 +3923,14 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/44/715dd4c43e1a4ba2c47ebd251240dd6aca0dd604cc1354932f0344f93b40/openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9", size = 323133, upload-time = "2026-03-05T19:22:26.623Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/2f/b7ba4f261d806aaab46f372d2049503ccedde373bb0648b88ebce58ebfe7/openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02", size = 411337, upload-time = "2026-03-05T19:22:29.369Z" },
{ url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" },
]
[[package]]
name = "openhands-tools"
version = "1.12.0"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -3943,9 +3943,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "tom-swe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/84/9552e75326c341707d36f7a86ba9a55a8fcb48bfd97e4d1ebe989260fdd8/openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c", size = 110293, upload-time = "2026-03-05T19:22:23.906Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/26/70031063c81bb1215f5a5d85c33c4e62e6a3d318dd8e3609e5ce68040faa/openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33", size = 150468, upload-time = "2026-03-05T19:22:24.974Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" },
]
[[package]]
@@ -7383,11 +7383,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
]
[[package]]
@@ -8528,21 +8528,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.4"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]