Compare commits

...

24 Commits

Author SHA1 Message Date
openhands 818f1ef6aa Fix failing Python tests
1. Fix remote_sandbox_service.py: Move None check before logging
   stored_sandbox.__dict__ to prevent AttributeError when sandbox
   doesn't exist

2. Fix test_docker_sandbox_spec_service_injector.py: Update assertion
   to check for '-python' substring instead of endswith('-python')
   to handle architecture suffix (e.g., '-python-amd64')

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-01 06:46:33 +00:00
Chuck Butkus 4d407aefdc Update agent server 2026-03-01 01:23:27 -05:00
Chuck Butkus faacfd48d0 Update agent server 2026-03-01 00:37:38 -05:00
openhands 64ad0afff3 fix: use query parameters for V1 git API endpoints to preserve path slashes
Update V1GitService to pass path as a query parameter instead of embedding
it in the URL path segment. This fixes URL path normalization issues with
Traefik/Gateway API where encoded slashes (%2F) in path segments would be
decoded and normalized, causing leading slashes to be lost.

For example, /workspace/project was arriving as workspace/project.

Using query parameters (e.g., ?path=/workspace/project) avoids this issue
as they are passed through without path normalization.

Requires corresponding backend change in software-agent-sdk.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-01 00:35:47 -05:00
Chuck Butkus aea51b3b47 Update agent sha 2026-02-28 23:09:59 -05:00
Chuck Butkus 60f921dbc0 Update agent server 2026-02-28 22:31:10 -05:00
Chuck Butkus bcdd8bb9e2 Update agent server version 2026-02-28 21:59:29 -05:00
Chuck Butkus bf9472511c Fix for agent image 2026-02-28 15:20:36 -05:00
Chuck Butkus ab0ff4c1ac Update agent version 2026-02-28 15:17:08 -05:00
Chuck Butkus 4fcf30e76b Fix runtime_id 2026-02-27 23:25:53 -05:00
Chuck Butkus 27708b98aa Add logging 2026-02-27 22:58:01 -05:00
Chuck Butkus f3dd2024b2 LInt fixes 2026-02-27 13:49:29 -05:00
openhands 42c3527e73 Update _build_service_url to support path-based runtimes
Update the _build_service_url function in remote_sandbox_service.py to handle
both path-based and subdomain-based routing, similar to the vscode_url method
in remote_runtime.py.

Changes:
- Add urlparse import to properly parse URL components
- Update _build_service_url to accept runtime_id parameter
- Detect path mode when URL path starts with /{runtime_id}
- For path mode: return {scheme}://{netloc}/{runtime_id}/{service_name}
- For subdomain mode: return {scheme}://{service_name}-{netloc}{path}
- Update all call sites to pass runtime_id
- Fix vscode_url query string separator (use ? instead of /?)
- Update and expand tests for both routing modes
2026-02-27 18:40:21 +00:00
openhands 87a8778210 Fix: Add DNS caching workaround for remote sandbox service
The external sandbox API allocates a DNS record when creating a sandbox,
but this record may take time to propagate externally. The httpx client
cached the connection (or failed connection) to similar hostnames,
causing requests to new sandbox URLs to fail.

This change adds a reuse_httpx_client flag that:
- Defaults to True (preserving existing behavior)
- When an HTTPError occurs, automatically switches to creating fresh
  clients for subsequent requests to force new DNS resolution
- Creates new client with async with block to ensure proper cleanup

Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 14:12:20 -05:00
Chuck Butkus 3640e1dadd Increase timeout again 2026-02-25 17:49:07 -05:00
Chuck Butkus c9cf433fea Increase conversation timeout 2026-02-25 17:14:28 -05:00
Chuck Butkus 8784681772 More logging 2026-02-25 16:33:16 -05:00
Chuck Butkus 38e65351a5 Update logging 2026-02-25 16:29:10 -05:00
Chuck Butkus d8d522bb1e Update logging 2026-02-25 16:01:31 -05:00
Chuck Butkus d1aed4cfc1 Add logging 2026-02-25 15:09:51 -05:00
Tim O'Farrell 36bb4d9e30 fix: prevent token refresh deadlock with double-checked locking and timeouts (#13020)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 15:15:49 +00:00
Tim O'Farrell c336a79727 Optimize get_sandbox_by_session_api_key with hash lookup (#13019)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 13:58:28 +00:00
Tim O'Farrell a57db2b5b2 Add webhook endpoint authentication bypass and admin context unfiltered data access (#12956)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 16:52:45 +00:00
Tim O'Farrell 929dcc39eb Bumped SDK to 1.11.5 (#13002) 2026-02-23 16:35:38 +00:00
23 changed files with 1994 additions and 153 deletions
@@ -0,0 +1,41 @@
"""Add session_api_key_hash to v1_remote_sandbox table
Revision ID: 097
Revises: 096
Create Date: 2025-02-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '097'
down_revision: Union[str, None] = '096'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add session_api_key_hash column to v1_remote_sandbox table."""
op.add_column(
'v1_remote_sandbox',
sa.Column('session_api_key_hash', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
'v1_remote_sandbox',
['session_api_key_hash'],
unique=False,
)
def downgrade() -> None:
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
op.drop_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
table_name='v1_remote_sandbox',
)
op.drop_column('v1_remote_sandbox', 'session_api_key_hash')
+13 -13
View File
@@ -6102,14 +6102,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.11.4"
version = "1.11.5"
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.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
]
[package.dependencies]
@@ -6126,7 +6126,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.3.0"
version = "1.4.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6168,9 +6168,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.2"
openhands-agent-server = "1.11.4"
openhands-sdk = "1.11.4"
openhands-tools = "1.11.4"
openhands-agent-server = "1.11.5"
openhands-sdk = "1.11.5"
openhands-tools = "1.11.5"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6225,14 +6225,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
]
[package.dependencies]
@@ -6253,14 +6253,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
]
[package.dependencies]
+6
View File
@@ -38,3 +38,9 @@ class ExpiredError(AuthError):
"""Error when a token has expired (Usually the refresh token)"""
pass
class TokenRefreshError(AuthError):
"""Error when token refresh fails due to timeout or lock contention"""
pass
+16 -4
View File
@@ -49,6 +49,10 @@ from openhands.integrations.service_types import ProviderType
from openhands.server.types import SessionExpiredError
from openhands.utils.http_session import httpx_verify_option
# HTTP timeout for external IDP calls (in seconds)
# This prevents indefinite blocking if an IDP is slow or unresponsive
IDP_HTTP_TIMEOUT = 15.0
def _before_sleep_callback(retry_state: RetryCallState) -> None:
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
@@ -202,7 +206,9 @@ class TokenManager:
access_token: str,
idp: ProviderType,
) -> dict[str, str | int]:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
headers = {
@@ -361,7 +367,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitHub token')
@@ -387,7 +395,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitLab token')
@@ -415,7 +425,9 @@ class TokenManager:
'refresh_token': refresh_token,
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket token')
+4 -1
View File
@@ -164,7 +164,6 @@ class SetAuthCookieMiddleware:
'/oauth/device/authorize',
'/oauth/device/token',
'/api/v1/web-client/config',
'/api/v1/webhooks/secrets',
)
if path in ignore_paths:
return False
@@ -175,6 +174,10 @@ class SetAuthCookieMiddleware:
):
return False
# Webhooks access is controlled using separate API keys
if path.startswith('/api/v1/webhooks/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp
@@ -24,6 +24,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
)
from openhands.app_server.errors import AuthError
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
@@ -63,6 +64,12 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
Raises:
AuthError: If no user_id is available (secure default: deny access)
"""
# For internal operations such as getting a conversation by session_api_key
# we need a mode that does not have filtering. The dependency `as_admin()`
# is used to enable it
if self.user_context == ADMIN:
return query
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
# Secure default: no user means no access, not "show everything"
+161 -64
View File
@@ -4,7 +4,9 @@ import time
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict
from sqlalchemy import select, update
from server.auth.auth_error import TokenRefreshError
from sqlalchemy import select, text, update
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import sessionmaker
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
@@ -12,6 +14,14 @@ from storage.database import a_session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
# Time buffer (in seconds) before actual expiration to consider token expired
# This ensures tokens are refreshed before they actually expire. The
# github default is 8 hours, so 15 minutes leeway is ~3% of this.
ACCESS_TOKEN_EXPIRY_BUFFER = 900 # 15 minutes
# Database lock timeout to prevent indefinite blocking
LOCK_TIMEOUT_SECONDS = 5
@dataclass
class AuthTokenStore:
@@ -23,6 +33,31 @@ class AuthTokenStore:
def identity_provider_value(self) -> str:
return self.idp.value
def _is_token_expired(
self, access_token_expires_at: int, refresh_token_expires_at: int
) -> tuple[bool, bool]:
"""Check if access and refresh tokens are expired.
Args:
access_token_expires_at: Expiration time for access token (seconds since epoch)
refresh_token_expires_at: Expiration time for refresh token (seconds since epoch)
Returns:
Tuple of (access_expired, refresh_expired)
"""
current_time = int(time.time())
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + ACCESS_TOKEN_EXPIRY_BUFFER
)
refresh_expired = (
False
if refresh_token_expires_at == 0
else refresh_token_expires_at < current_time
)
return access_expired, refresh_expired
async def store_tokens(
self,
access_token: str,
@@ -73,87 +108,149 @@ class AuthTokenStore:
]
| None = None,
) -> Dict[str, str | int] | None:
"""
Load authentication tokens from the database and refresh them if necessary.
"""Load authentication tokens from the database and refresh them if necessary.
This method retrieves the current authentication tokens for the user and checks if they have expired.
It uses the provided `check_expiration_and_refresh` function to determine if the tokens need
to be refreshed and to refresh the tokens if needed.
This method uses a double-checked locking pattern to minimize lock contention:
1. First, check if the token is valid WITHOUT acquiring a lock (fast path)
2. If refresh is needed, acquire a lock with a timeout
3. Double-check if refresh is still needed (another request may have refreshed)
4. Perform the refresh if still needed
The method ensures that only one refresh operation is performed per refresh token by using a
row-level lock on the token record.
The method is designed to handle race conditions where multiple requests might attempt to refresh
the same token simultaneously, ensuring that only one refresh call occurs per refresh token.
The row-level lock ensures that only one refresh operation is performed per
refresh token, which is important because most IDPs invalidate the old refresh
token after it's used once.
Args:
check_expiration_and_refresh (Callable, optional): A function that checks if the tokens have expired
and attempts to refresh them. It should return a dictionary containing the new access_token, refresh_token,
and their respective expiration timestamps. If no refresh is needed, it should return `None`.
check_expiration_and_refresh: A function that checks if the tokens have
expired and attempts to refresh them. It should return a dictionary
containing the new access_token, refresh_token, and their respective
expiration timestamps. If no refresh is needed, it should return None.
Returns:
Dict[str, str | int] | None:
A dictionary containing the access_token, refresh_token, access_token_expires_at,
and refresh_token_expires_at. If no token record is found, returns `None`.
A dictionary containing the access_token, refresh_token,
access_token_expires_at, and refresh_token_expires_at.
If no token record is found, returns None.
Raises:
TokenRefreshError: If the lock cannot be acquired within the timeout
period. This typically means another request is holding the lock
for an extended period. Callers should handle this by returning
a 401 response to prompt the user to re-authenticate.
"""
# FAST PATH: Check without lock first to avoid unnecessary lock contention
async with self.a_session_maker() as session:
async with session.begin(): # Ensures transaction management
# Lock the row while we check if we need to refresh the tokens.
# There is a race condition where 2 or more calls can load tokens simultaneously.
# If it turns out the loaded tokens are expired, then there will be multiple
# refresh token calls with the same refresh token. Most IDPs only allow one refresh
# per refresh token. This lock ensure that only one refresh call occurs per refresh token
result = await session.execute(
select(AuthTokens)
.filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider == self.identity_provider_value,
)
.with_for_update()
result = await session.execute(
select(AuthTokens).filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider == self.identity_provider_value,
)
token_record = result.scalars().one_or_none()
)
token_record = result.scalars().one_or_none()
if not token_record:
return None
if not token_record:
return None
token_refresh = (
await check_expiration_and_refresh(
# Check if token needs refresh
access_expired, _ = self._is_token_expired(
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
# If token is still valid, return it without acquiring a lock
if not access_expired or check_expiration_and_refresh is None:
return {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
# SLOW PATH: Token needs refresh, acquire lock
try:
async with self.a_session_maker() as session:
async with session.begin():
# Set a lock timeout to prevent indefinite blocking
# This ensures we don't hold connections forever if something goes wrong
await session.execute(
text(f"SET LOCAL lock_timeout = '{LOCK_TIMEOUT_SECONDS}s'")
)
# Acquire row-level lock to prevent concurrent refresh attempts
result = await session.execute(
select(AuthTokens)
.filter(
AuthTokens.keycloak_user_id == self.keycloak_user_id,
AuthTokens.identity_provider
== self.identity_provider_value,
)
.with_for_update()
)
token_record = result.scalars().one_or_none()
if not token_record:
return None
# Double-check: another request may have refreshed while we waited for the lock
access_expired, _ = self._is_token_expired(
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
if not access_expired:
# Token was refreshed by another request while we waited
logger.debug(
'Token was refreshed by another request while waiting for lock'
)
return {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
# We're the one doing the refresh
token_refresh = await check_expiration_and_refresh(
self.idp,
token_record.refresh_token,
token_record.access_token_expires_at,
token_record.refresh_token_expires_at,
)
if check_expiration_and_refresh
else None
)
if token_refresh:
await session.execute(
update(AuthTokens)
.where(AuthTokens.id == token_record.id)
.values(
access_token=token_refresh['access_token'],
refresh_token=token_refresh['refresh_token'],
access_token_expires_at=token_refresh[
'access_token_expires_at'
],
refresh_token_expires_at=token_refresh[
'refresh_token_expires_at'
],
if token_refresh:
await session.execute(
update(AuthTokens)
.where(AuthTokens.id == token_record.id)
.values(
access_token=token_refresh['access_token'],
refresh_token=token_refresh['refresh_token'],
access_token_expires_at=token_refresh[
'access_token_expires_at'
],
refresh_token_expires_at=token_refresh[
'refresh_token_expires_at'
],
)
)
)
await session.commit()
await session.commit()
return (
token_refresh
if token_refresh
else {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
)
return (
token_refresh
if token_refresh
else {
'access_token': token_record.access_token,
'refresh_token': token_record.refresh_token,
'access_token_expires_at': token_record.access_token_expires_at,
'refresh_token_expires_at': token_record.refresh_token_expires_at,
}
)
except OperationalError as e:
# Lock timeout - another request is holding the lock for too long
logger.warning(
f'Token refresh lock timeout for user {self.keycloak_user_id}: {e}'
)
raise TokenRefreshError(
'Unable to refresh token due to lock timeout. Please try again.'
) from e
async def is_access_token_valid(self) -> bool:
"""Check if the access token is still valid.
@@ -194,8 +291,8 @@ class AuthTokenStore:
"""Get an instance of the AuthTokenStore.
Args:
config: The application configuration
keycloak_user_id: The Keycloak user ID
idp: The identity provider type
Returns:
An instance of AuthTokenStore
@@ -0,0 +1,661 @@
"""Unit tests for AuthTokenStore."""
import time
from contextlib import asynccontextmanager
from typing import Dict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.auth.auth_error import TokenRefreshError
from sqlalchemy.exc import OperationalError
from storage.auth_token_store import (
ACCESS_TOKEN_EXPIRY_BUFFER,
LOCK_TIMEOUT_SECONDS,
AuthTokenStore,
)
from openhands.integrations.service_types import ProviderType
def create_mock_session():
"""Create a mock async session with properly configured context managers."""
session = AsyncMock()
# Create async context manager for begin()
@asynccontextmanager
async def begin_context():
yield
session.begin = begin_context
return session
def create_mock_session_maker(mock_session):
"""Create a mock async session maker."""
@asynccontextmanager
async def session_context():
yield mock_session
# Return a callable that returns the context manager
return lambda: session_context()
@pytest.fixture
def mock_session():
"""Create mock async session."""
return create_mock_session()
@pytest.fixture
def mock_session_maker(mock_session):
"""Create mock async session maker."""
return create_mock_session_maker(mock_session)
@pytest.fixture
def auth_token_store(mock_session_maker):
"""Create AuthTokenStore instance with mocked session maker."""
return AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
class TestIsTokenExpired:
"""Tests for _is_token_expired method."""
def test_both_tokens_valid(self, auth_token_store):
"""Test when both tokens are valid (not expired)."""
current_time = int(time.time())
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
refresh_expires = current_time + 1000
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is False
assert refresh_expired is False
def test_access_token_expired(self, auth_token_store):
"""Test when access token is expired but within buffer."""
current_time = int(time.time())
# Access token expires within buffer period
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER - 100
refresh_expires = current_time + 10000
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is True
assert refresh_expired is False
def test_refresh_token_expired(self, auth_token_store):
"""Test when refresh token is expired."""
current_time = int(time.time())
access_expires = current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
refresh_expires = current_time - 100 # Already expired
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is False
assert refresh_expired is True
def test_both_tokens_expired(self, auth_token_store):
"""Test when both tokens are expired."""
current_time = int(time.time())
access_expires = current_time - 100
refresh_expires = current_time - 100
access_expired, refresh_expired = auth_token_store._is_token_expired(
access_expires, refresh_expires
)
assert access_expired is True
assert refresh_expired is True
def test_zero_expiration_treated_as_never_expires(self, auth_token_store):
"""Test that 0 expiration time is treated as never expires."""
access_expired, refresh_expired = auth_token_store._is_token_expired(0, 0)
assert access_expired is False
assert refresh_expired is False
class TestLoadTokensFastPath:
"""Tests for load_tokens fast path (no lock needed)."""
@pytest.mark.asyncio
async def test_fast_path_token_not_found(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns None when no token record exists."""
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens()
assert result is None
@pytest.mark.asyncio
async def test_fast_path_valid_token_no_refresh_needed(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns tokens when they are still valid."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'valid-access-token'
mock_token.refresh_token = 'valid-refresh-token'
mock_token.access_token_expires_at = (
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
)
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens()
assert result is not None
assert result['access_token'] == 'valid-access-token'
assert result['refresh_token'] == 'valid-refresh-token'
@pytest.mark.asyncio
async def test_fast_path_no_refresh_callback_provided(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test fast path returns existing tokens when no refresh callback is provided."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'expired-access-token'
mock_token.refresh_token = 'valid-refresh-token'
# Expired access token
mock_token.access_token_expires_at = current_time - 100
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.load_tokens(check_expiration_and_refresh=None)
assert result is not None
assert result['access_token'] == 'expired-access-token'
class TestLoadTokensSlowPath:
"""Tests for load_tokens slow path (lock required for refresh)."""
@pytest.mark.asyncio
async def test_slow_path_successful_refresh(self):
"""Test slow path successfully refreshes expired tokens."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - returns expired token
# Second call (slow path) - returns same token for update
expired_token = MagicMock()
expired_token.id = 1
expired_token.access_token = 'expired-access-token'
expired_token.refresh_token = 'valid-refresh-token'
expired_token.access_token_expires_at = current_time - 100 # Expired
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int]:
return {
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert result is not None
assert result['access_token'] == 'new-access-token'
assert result['refresh_token'] == 'new-refresh-token'
@pytest.mark.asyncio
async def test_slow_path_double_check_avoids_refresh(self):
"""Test double-check locking: token was refreshed by another request."""
current_time = int(time.time())
mock_session = create_mock_session()
# Simulate scenario:
# 1. Fast path sees expired token
# 2. While waiting for lock, another request refreshes
# 3. Slow path sees fresh token, skips refresh
call_count = [0]
def create_token():
call_count[0] += 1
token = MagicMock()
token.id = 1
token.access_token = 'fresh-access-token'
token.refresh_token = 'fresh-refresh-token'
if call_count[0] == 1:
# First call (fast path) - expired
token.access_token_expires_at = current_time - 100
else:
# Second call (slow path) - already refreshed
token.access_token_expires_at = (
current_time + ACCESS_TOKEN_EXPIRY_BUFFER + 1000
)
token.refresh_token_expires_at = current_time + 86400
return token
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.side_effect = (
lambda: create_token()
)
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
refresh_called = [False]
async def mock_refresh(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int]:
refresh_called[0] = True
return {
'access_token': 'should-not-be-used',
'refresh_token': 'should-not-be-used',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
# The refresh callback should not be called because double-check
# found the token was already refreshed
assert result is not None
assert result['access_token'] == 'fresh-access-token'
@pytest.mark.asyncio
async def test_slow_path_token_not_found_after_lock(self):
"""Test slow path returns None if token record disappears after lock."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - token exists but expired
# Second call (slow path with lock) - token no longer exists
call_count = [0]
def get_token():
call_count[0] += 1
if call_count[0] == 1:
token = MagicMock()
token.access_token_expires_at = current_time - 100 # Expired
token.refresh_token_expires_at = current_time + 10000
return token
return None
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.side_effect = get_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
result = await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert result is None
class TestLoadTokensLockTimeout:
"""Tests for lock timeout handling."""
@pytest.mark.asyncio
async def test_lock_timeout_raises_token_refresh_error(self):
"""Test that lock timeout raises TokenRefreshError."""
current_time = int(time.time())
mock_session = create_mock_session()
# First call (fast path) - returns expired token
expired_token = MagicMock()
expired_token.access_token_expires_at = current_time - 100
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
# First execute for fast path succeeds
# Second execute (for slow path) raises OperationalError
call_count = [0]
async def execute_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] <= 1:
return mock_result
# Simulate lock timeout
raise OperationalError(
'canceling statement due to lock timeout', None, None
)
mock_session.execute = execute_side_effect
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
with pytest.raises(TokenRefreshError) as exc_info:
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
assert 'lock timeout' in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_lock_timeout_preserves_original_exception(self):
"""Test that TokenRefreshError preserves the original OperationalError."""
current_time = int(time.time())
mock_session = create_mock_session()
expired_token = MagicMock()
expired_token.access_token_expires_at = current_time - 100
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
original_error = OperationalError(
'canceling statement due to lock timeout', None, None
)
call_count = [0]
async def execute_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] <= 1:
return mock_result
raise original_error
mock_session.execute = execute_side_effect
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh(*args) -> Dict[str, str | int]:
return {
'access_token': 'new-token',
'refresh_token': 'new-refresh',
'access_token_expires_at': current_time + 3600,
'refresh_token_expires_at': current_time + 86400,
}
with pytest.raises(TokenRefreshError) as exc_info:
await auth_store.load_tokens(check_expiration_and_refresh=mock_refresh)
# Verify the original exception is chained
assert exc_info.value.__cause__ is original_error
class TestLoadTokensRefreshCallbackBehavior:
"""Tests for refresh callback return values."""
@pytest.mark.asyncio
async def test_refresh_callback_returns_none(self):
"""Test behavior when refresh callback returns None (no refresh performed)."""
current_time = int(time.time())
mock_session = create_mock_session()
expired_token = MagicMock()
expired_token.id = 1
expired_token.access_token = 'old-access-token'
expired_token.refresh_token = 'old-refresh-token'
expired_token.access_token_expires_at = current_time - 100 # Expired
expired_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = expired_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
async def mock_refresh_returns_none(
idp: ProviderType, refresh_token: str, access_exp: int, refresh_exp: int
) -> Dict[str, str | int] | None:
return None
result = await auth_store.load_tokens(
check_expiration_and_refresh=mock_refresh_returns_none
)
# Should return the old tokens when refresh returns None
assert result is not None
assert result['access_token'] == 'old-access-token'
assert result['refresh_token'] == 'old-refresh-token'
class TestStoreTokens:
"""Tests for store_tokens method."""
@pytest.mark.asyncio
async def test_store_tokens_creates_new_record(self):
"""Test storing tokens when no existing record."""
mock_session = create_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
await auth_store.store_tokens(
access_token='new-access-token',
refresh_token='new-refresh-token',
access_token_expires_at=1234567890,
refresh_token_expires_at=1234657890,
)
mock_session.add.assert_called_once()
@pytest.mark.asyncio
async def test_store_tokens_updates_existing_record(self):
"""Test storing tokens updates existing record."""
mock_session = create_mock_session()
existing_token = MagicMock()
existing_token.access_token = 'old-access'
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = existing_token
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session_maker = create_mock_session_maker(mock_session)
auth_store = AuthTokenStore(
keycloak_user_id='test-user-123',
idp=ProviderType.GITHUB,
a_session_maker=mock_session_maker,
)
await auth_store.store_tokens(
access_token='new-access-token',
refresh_token='new-refresh-token',
access_token_expires_at=1234567890,
refresh_token_expires_at=1234657890,
)
assert existing_token.access_token == 'new-access-token'
assert existing_token.refresh_token == 'new-refresh-token'
class TestIsAccessTokenValid:
"""Tests for is_access_token_valid method."""
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_false_when_no_tokens(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns False when no tokens found."""
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is False
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_true_for_valid_token(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns True when token is valid."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'valid-access'
mock_token.refresh_token = 'valid-refresh'
mock_token.access_token_expires_at = current_time + 1000
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is True
@pytest.mark.asyncio
async def test_is_access_token_valid_returns_false_for_expired_token(
self, auth_token_store, mock_session_maker, mock_session
):
"""Test returns False when token is expired."""
current_time = int(time.time())
mock_token = MagicMock()
mock_token.access_token = 'expired-access'
mock_token.refresh_token = 'valid-refresh'
mock_token.access_token_expires_at = current_time - 100 # Expired
mock_token.refresh_token_expires_at = current_time + 10000
mock_result = MagicMock()
mock_result.scalars.return_value.one_or_none.return_value = mock_token
mock_session.execute = AsyncMock(return_value=mock_result)
result = await auth_token_store.is_access_token_valid()
assert result is False
class TestGetInstance:
"""Tests for get_instance class method."""
@pytest.mark.asyncio
async def test_get_instance_creates_auth_token_store(self):
"""Test get_instance creates an AuthTokenStore with correct params."""
with patch('storage.auth_token_store.a_session_maker') as mock_a_session_maker:
store = await AuthTokenStore.get_instance(
keycloak_user_id='user-123', idp=ProviderType.GITHUB
)
assert store.keycloak_user_id == 'user-123'
assert store.idp == ProviderType.GITHUB
assert store.a_session_maker is mock_a_session_maker
class TestIdentityProviderValue:
"""Tests for identity_provider_value property."""
def test_identity_provider_value_returns_idp_value(self, auth_token_store):
"""Test that identity_provider_value returns the enum value."""
assert auth_token_store.identity_provider_value == ProviderType.GITHUB.value
def test_identity_provider_value_for_different_providers(self):
"""Test identity_provider_value for different providers."""
for provider in [
ProviderType.GITHUB,
ProviderType.GITLAB,
ProviderType.BITBUCKET,
]:
store = AuthTokenStore(
keycloak_user_id='test-user',
idp=provider,
a_session_maker=MagicMock(),
)
assert store.identity_provider_value == provider.value
class TestConstants:
"""Tests for module constants."""
def test_access_token_expiry_buffer_value(self):
"""Test ACCESS_TOKEN_EXPIRY_BUFFER is set to 15 minutes."""
assert ACCESS_TOKEN_EXPIRY_BUFFER == 900
def test_lock_timeout_seconds_value(self):
"""Test LOCK_TIMEOUT_SECONDS is set to 5 seconds."""
assert LOCK_TIMEOUT_SECONDS == 5
@@ -486,3 +486,180 @@ class TestSaasSQLAppConversationInfoService:
# Count should be 0 in org2
count_org2 = await user1_service_org2.count_app_conversation_info()
assert count_org2 == 0
class TestSaasSQLAppConversationInfoServiceAdminContext:
"""Test suite for SaasSQLAppConversationInfoService with ADMIN context."""
@pytest.mark.asyncio
async def test_admin_context_returns_unfiltered_data(
self,
async_session_with_users: AsyncSession,
):
"""Test that ADMIN context returns unfiltered data (no user/org filtering)."""
# Create conversations for different users
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create conversations for user1 in org1
for i in range(3):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id=f'sandbox_user1_{i}',
title=f'User1 Conversation {i}',
)
await user1_service.save_app_conversation_info(conv)
# Now create an ADMIN service
from openhands.app_server.user.specifiy_user_context import ADMIN
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# ADMIN should see ALL conversations (unfiltered)
admin_page = await admin_service.search_app_conversation_info()
assert (
len(admin_page.items) == 3
), 'ADMIN context should see all conversations without filtering'
# ADMIN count should return total count (3)
admin_count = await admin_service.count_app_conversation_info()
assert (
admin_count == 3
), 'ADMIN context should count all conversations without filtering'
@pytest.mark.asyncio
async def test_admin_context_can_access_any_conversation(
self,
async_session_with_users: AsyncSession,
):
"""Test that ADMIN context can access any conversation regardless of owner."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create a conversation as user1
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_user1',
title='User1 Private Conversation',
)
await user1_service.save_app_conversation_info(conv)
# Create a service as user2 in org2 - should not see user1's conversation
user2_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
)
user2_page = await user2_service.search_app_conversation_info()
assert len(user2_page.items) == 0, 'User2 should not see User1 conversation'
# But ADMIN should see ALL conversations including user1's
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 1
assert admin_page.items[0].id == conv.id
# ADMIN should also be able to get specific conversation by ID
admin_get_conv = await admin_service.get_app_conversation_info(conv.id)
assert admin_get_conv is not None
assert admin_get_conv.id == conv.id
@pytest.mark.asyncio
async def test_secure_select_admin_bypasses_filtering(
self,
async_session_with_users: AsyncSession,
):
"""Test that _secure_select returns unfiltered query for ADMIN context."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create an ADMIN service
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
# Get the secure select query
query = await admin_service._secure_select()
# Convert query to string to verify NO filters are present
query_str = str(query.compile(compile_kwargs={'literal_binds': True}))
# For ADMIN, there should be no user_id or org_id filtering
# The query should not contain filters for user_id or org_id
assert str(USER1_ID) not in query_str.replace(
'-', ''
), 'ADMIN context should not filter by user_id'
assert str(USER2_ID) not in query_str.replace(
'-', ''
), 'ADMIN context should not filter by user_id'
@pytest.mark.asyncio
async def test_regular_user_context_filters_correctly(
self,
async_session_with_users: AsyncSession,
):
"""Test that regular user context properly filters data (control test)."""
from openhands.app_server.user.specifiy_user_context import ADMIN
# Create conversations for different users
user1_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
)
# Create 3 conversations for user1
for i in range(3):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER1_ID),
sandbox_id=f'sandbox_user1_{i}',
title=f'User1 Conversation {i}',
)
await user1_service.save_app_conversation_info(conv)
# Create 2 conversations for user2
user2_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
)
for i in range(2):
conv = AppConversationInfo(
id=uuid4(),
created_by_user_id=str(USER2_ID),
sandbox_id=f'sandbox_user2_{i}',
title=f'User2 Conversation {i}',
)
await user2_service.save_app_conversation_info(conv)
# User1 should only see their 3 conversations
user1_page = await user1_service.search_app_conversation_info()
assert len(user1_page.items) == 3
# User2 should only see their 2 conversations
user2_page = await user2_service.search_app_conversation_info()
assert len(user2_page.items) == 2
# But ADMIN should see all 5 conversations
admin_service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=ADMIN,
)
admin_page = await admin_service.search_app_conversation_info()
assert len(admin_page.items) == 5
@@ -284,3 +284,85 @@ async def test_middleware_ignores_email_resend_path_no_tos_check(
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
# Should not raise TosNotAcceptedError for this path
@pytest.mark.asyncio
async def test_middleware_skips_webhook_endpoints(
middleware, mock_request, mock_response
):
"""Test middleware skips webhook endpoints (/api/v1/webhooks/*) and doesn't require auth."""
# Test various webhook paths
webhook_paths = [
'/api/v1/webhooks/events',
'/api/v1/webhooks/events/123',
'/api/v1/webhooks/stats',
'/api/v1/webhooks/parent-conversation',
]
for path in webhook_paths:
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = path
mock_call_next = AsyncMock(return_value=mock_response)
# Act
result = await middleware(mock_request, mock_call_next)
# Assert - middleware should skip auth check and call next
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
@pytest.mark.asyncio
async def test_middleware_skips_webhook_secrets_endpoint(
middleware, mock_request, mock_response
):
"""Test middleware skips the old /api/v1/webhooks/secrets endpoint."""
# This was explicitly in ignore_paths but is now handled by the prefix check
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = '/api/v1/webhooks/secrets'
mock_call_next = AsyncMock(return_value=mock_response)
# Act
result = await middleware(mock_request, mock_call_next)
# Assert - middleware should skip auth check and call next
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
@pytest.mark.asyncio
async def test_middleware_does_not_skip_similar_non_webhook_paths(
middleware, mock_response
):
"""Test middleware does NOT skip paths that start with /api/v1/webhook (without 's')."""
# These paths should still be processed by the middleware (not skipped)
# They start with /api so _should_attach returns True, and since there's no auth,
# middleware should return 401 response (it catches NoCredentialsError internally)
non_webhook_paths = [
'/api/v1/webhook/events',
'/api/v1/webhook/something',
]
for path in non_webhook_paths:
# Create a fresh mock request for each test
mock_request = MagicMock(spec=Request)
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = path
mock_request.headers = MagicMock()
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
# Since these paths start with /api, _should_attach returns True
# Since there's no auth, middleware catches NoCredentialsError and returns 401
mock_call_next = AsyncMock()
result = await middleware(mock_request, mock_call_next)
# Should return a 401 response, not raise an exception
assert result.status_code == status.HTTP_401_UNAUTHORIZED
# Should NOT call next for non-webhook paths when auth is missing
mock_call_next.assert_not_called()
@@ -30,7 +30,7 @@ class V1GitService {
/**
* Get git changes for a V1 conversation
* Uses the agent server endpoint: GET /api/git/changes/{path}
* Uses the agent server endpoint: GET /api/git/changes?path={path}
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
@@ -43,15 +43,14 @@ class V1GitService {
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/changes/${encodedPath}`,
);
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/changes`);
const headers = buildSessionHeaders(sessionApiKey);
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
const { data } = await axios.get<V1GitChange[]>(url, { headers });
const { data } = await axios.get<V1GitChange[]>(url, {
headers,
params: { path },
});
// Validate response is an array (could be HTML error page if runtime is dead)
if (!Array.isArray(data)) {
@@ -69,7 +68,7 @@ class V1GitService {
/**
* Get git change diff for a specific file in a V1 conversation
* Uses the agent server endpoint: GET /api/git/diff/{path}
* Uses the agent server endpoint: GET /api/git/diff?path={path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
@@ -81,14 +80,13 @@ class V1GitService {
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/diff/${encodedPath}`,
);
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/diff`);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<GitChangeDiff>(url, { headers });
const { data } = await axios.get<GitChangeDiff>(url, {
headers,
params: { path },
});
return data;
}
}
@@ -1495,7 +1495,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_timeout: int = Field(
default=120, description='The max timeout time for sandbox startup'
default=600, description='The max timeout time for sandbox startup'
)
sandbox_startup_poll_frequency: int = Field(
default=2, description='The frequency to poll for sandbox readiness'
@@ -0,0 +1,38 @@
"""Add session_api_key_hash to v1_remote_sandbox table
Revision ID: 006
Revises: 005
Create Date: 2025-02-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '006'
down_revision: Union[str, None] = '005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add session_api_key_hash column to v1_remote_sandbox table."""
with op.batch_alter_table('v1_remote_sandbox') as batch_op:
batch_op.add_column(
sa.Column('session_api_key_hash', sa.String(), nullable=True)
)
batch_op.create_index(
'ix_v1_remote_sandbox_session_api_key_hash',
['session_api_key_hash'],
unique=False,
)
def downgrade() -> None:
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
with op.batch_alter_table('v1_remote_sandbox') as batch_op:
batch_op.drop_index('ix_v1_remote_sandbox_session_api_key_hash')
batch_op.drop_column('session_api_key_hash')
@@ -62,7 +62,7 @@ async def valid_sandbox(
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
if session_api_key is None:
if not session_api_key:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
)
@@ -144,7 +144,6 @@ async def on_event(
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
)
@@ -1,8 +1,11 @@
import asyncio
import hashlib
import json
import logging
import os
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Union
from urllib.parse import urlparse
from uuid import UUID
import base62
@@ -72,6 +75,11 @@ WORKER_1_PORT = 12000
WORKER_2_PORT = 12001
def _hash_session_api_key(session_api_key: str) -> str:
"""Hash a session API key using SHA-256."""
return hashlib.sha256(session_api_key.encode()).hexdigest()
class StoredRemoteSandbox(Base): # type: ignore
"""Local storage for remote sandbox info.
@@ -84,6 +92,7 @@ class StoredRemoteSandbox(Base): # type: ignore
id = Column(String, primary_key=True)
created_by_user_id = Column(String, nullable=True, index=True)
sandbox_spec_id = Column(String, index=True) # shadows runtime['image']
session_api_key_hash = Column(String, nullable=True, index=True)
created_at = Column(UtcDateTime, server_default=func.now(), index=True)
@@ -106,21 +115,51 @@ class RemoteSandboxService(SandboxService):
user_context: UserContext
httpx_client: httpx.AsyncClient
db_session: AsyncSession
# Flag to control whether to reuse the cached httpx client or create a new one.
# When True (default), uses the injected client for efficiency.
# When False, creates a new client for each request to avoid DNS caching issues.
reuse_httpx_client: bool = True
async def _send_runtime_api_request(
self, method: str, path: str, **kwargs: Any
) -> httpx.Response:
"""Send a request to the remote runtime API."""
"""Send a request to the remote runtime API.
When reuse_httpx_client is False, creates a new client for each request
to work around DNS caching issues where newly allocated sandbox URLs
may not be immediately resolvable externally.
"""
url = self.api_url + path
headers = {'X-API-Key': self.api_key}
# Create a fresh client to force new DNS resolution for each request
if not self.reuse_httpx_client:
async with httpx.AsyncClient(
timeout=self.httpx_client.timeout
) as new_client:
try:
return await new_client.request(
method, url, headers=headers, **kwargs
)
except httpx.HTTPError:
# Keep reuse_httpx_client False for subsequent requests
raise
# Default: reuse the injected client for efficiency
try:
url = self.api_url + path
return await self.httpx_client.request(
method, url, headers={'X-API-Key': self.api_key}, **kwargs
method, url, headers=headers, **kwargs
)
except httpx.TimeoutException:
_logger.error(f'No response received within timeout for URL: {url}')
raise
except httpx.HTTPError as e:
_logger.error(f'HTTP error for URL {url}: {e}')
if self.reuse_httpx_client:
_logger.warning(
f'HTTP error for URL {url}: {e}. Disabling httpx client reuse to '
'force fresh DNS resolution for future requests.'
)
self.reuse_httpx_client = False
raise
def _to_sandbox_info(
@@ -130,17 +169,23 @@ class RemoteSandboxService(SandboxService):
# Get session_api_key and exposed urls
if runtime:
_logger.info(
f'Getting session_api_key and exposed urls for runtime: {runtime}'
)
session_api_key = runtime['session_api_key']
if status == SandboxStatus.RUNNING:
exposed_urls = []
url = runtime.get('url', None)
if url:
_logger.info(f'Building exposed urls for runtime url: {url}')
runtime_id = runtime['runtime_id']
_logger.info(f'Runtime ID for URL building: {runtime_id}')
exposed_urls.append(
ExposedUrl(name=AGENT_SERVER, url=url, port=AGENT_SERVER_PORT)
)
vscode_url = (
_build_service_url(url, 'vscode')
+ f'/?tkn={session_api_key}&folder=%2Fworkspace%2Fproject'
_build_service_url(url, 'vscode', runtime_id)
+ f'?tkn={session_api_key}&folder=%2Fworkspace%2Fproject'
)
exposed_urls.append(
ExposedUrl(name=VSCODE, url=vscode_url, port=VSCODE_PORT)
@@ -148,14 +193,14 @@ class RemoteSandboxService(SandboxService):
exposed_urls.append(
ExposedUrl(
name=WORKER_1,
url=_build_service_url(url, 'work-1'),
url=_build_service_url(url, 'work-1', runtime_id),
port=WORKER_1_PORT,
)
)
exposed_urls.append(
ExposedUrl(
name=WORKER_2,
url=_build_service_url(url, 'work-2'),
url=_build_service_url(url, 'work-2', runtime_id),
port=WORKER_2_PORT,
)
)
@@ -165,6 +210,7 @@ class RemoteSandboxService(SandboxService):
session_api_key = None
exposed_urls = None
_logger.info(f'exposed_urls: {exposed_urls}')
sandbox_spec_id = stored.sandbox_spec_id
return SandboxInfo(
id=stored.id,
@@ -189,8 +235,10 @@ class RemoteSandboxService(SandboxService):
status = None
pod_status = (runtime.get('pod_status') or '').lower()
_logger.info(f'pod status: {pod_status}')
if pod_status:
status = POD_STATUS_MAPPING.get(pod_status, None)
_logger.info(f'status: {status}')
# If we failed to get the status from the pod status, fall back to status
if status is None:
@@ -329,13 +377,18 @@ class RemoteSandboxService(SandboxService):
async def get_sandbox(self, sandbox_id: str) -> Union[SandboxInfo, None]:
"""Get a single sandbox by checking its corresponding runtime."""
_logger.info(f'Getting sandbox with id: {sandbox_id}', stack_info=True)
stored_sandbox = await self._get_stored_sandbox(sandbox_id)
if stored_sandbox is None:
_logger.info('Got sandbox: None')
return None
_logger.info(f'Got sandbox: {json.dumps(stored_sandbox.__dict__, default=str)}')
runtime = None
try:
_logger.info(f'Getting runtime for sandbox id: {stored_sandbox.id}')
runtime = await self._get_runtime(stored_sandbox.id)
_logger.info(f'Got runtime: {runtime}')
except Exception:
_logger.exception(
f'Error getting runtime: {stored_sandbox.id}', stack_info=True
@@ -343,12 +396,14 @@ class RemoteSandboxService(SandboxService):
return self._to_sandbox_info(stored_sandbox, runtime)
async def get_sandbox_by_session_api_key(
async def _get_sandbox_by_session_api_key_legacy(
self, session_api_key: str
) -> Union[SandboxInfo, None]:
"""Get a single sandbox by session API key."""
# TODO: We should definitely refactor this and store the session_api_key in
# the v1_remote_sandbox table
"""Legacy method to get sandbox by session API key via runtime API.
This is the fallback for sandboxes created before the session_api_key_hash
column was added. It calls the remote runtime API which is less efficient.
"""
try:
response = await self._send_runtime_api_request(
'GET',
@@ -366,6 +421,10 @@ class RemoteSandboxService(SandboxService):
sandbox = result.scalar_one_or_none()
if sandbox is None:
raise ValueError('sandbox_not_found')
# Backfill the hash for future lookups (Auto committed at end of request)
sandbox.session_api_key_hash = _hash_session_api_key(
session_api_key
)
return self._to_sandbox_info(sandbox, runtime)
except Exception:
_logger.exception(
@@ -382,6 +441,10 @@ class RemoteSandboxService(SandboxService):
try:
runtime = await self._get_runtime(stored_sandbox.id)
if runtime and runtime.get('session_api_key') == session_api_key:
# Backfill the hash for future lookups (Auto committed at end of request)
stored_sandbox.session_api_key_hash = _hash_session_api_key(
session_api_key
)
return self._to_sandbox_info(stored_sandbox, runtime)
except Exception:
# Continue checking other sandboxes if one fails
@@ -389,6 +452,39 @@ class RemoteSandboxService(SandboxService):
return None
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> Union[SandboxInfo, None]:
"""Get a single sandbox by session API key.
Uses the stored session_api_key_hash for efficient database lookup instead
of calling the remote runtime API. Falls back to legacy API-based lookup
for sandboxes created before the hash column was added.
"""
session_api_key_hash = _hash_session_api_key(session_api_key)
# First try to find sandbox by hash in the database
stmt = await self._secure_select()
stmt = stmt.where(
StoredRemoteSandbox.session_api_key_hash == session_api_key_hash
)
result = await self.db_session.execute(stmt)
stored_sandbox = result.scalar_one_or_none()
if stored_sandbox:
try:
runtime = await self._get_runtime(stored_sandbox.id)
return self._to_sandbox_info(stored_sandbox, runtime)
except Exception:
_logger.exception(
f'Error getting runtime for sandbox {stored_sandbox.id}',
stack_info=True,
)
return self._to_sandbox_info(stored_sandbox, None)
# Fallback for sandboxes created before the hash column was added
return await self._get_sandbox_by_session_api_key_legacy(session_api_key)
async def start_sandbox(
self, sandbox_spec_id: str | None = None, sandbox_id: str | None = None
) -> SandboxInfo:
@@ -455,6 +551,13 @@ class RemoteSandboxService(SandboxService):
response.raise_for_status()
runtime_data = response.json()
# Store the session_api_key hash for efficient lookups
session_api_key = runtime_data.get('session_api_key')
if session_api_key:
stored_sandbox.session_api_key_hash = _hash_session_api_key(
session_api_key
)
# Hack - result doesn't contain this
runtime_data['pod_status'] = 'pending'
@@ -602,9 +705,21 @@ class RemoteSandboxService(SandboxService):
return results
def _build_service_url(url: str, service_name: str):
scheme, host_and_path = url.split('://')
return scheme + '://' + service_name + '-' + host_and_path
def _build_service_url(url: str, service_name: str, runtime_id: str) -> str:
"""Build a service URL for the given service name.
Handles both path-based and subdomain-based routing:
- Path mode (url path starts with /{runtime_id}): returns {scheme}://{netloc}/{runtime_id}/{service_name}
- Subdomain mode: returns {scheme}://{service_name}-{netloc}{path}
"""
parsed = urlparse(url)
scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/'
# Path mode if runtime_url path starts with /{id}
path_mode = path.startswith(f'/{runtime_id}')
if path_mode:
return f'{scheme}://{netloc}/{runtime_id}/{service_name}'
else:
return f'{scheme}://{service_name}-{netloc}{path}'
async def poll_agent_servers(api_url: str, api_key: str, sleep_interval: int):
@@ -78,7 +78,7 @@ class SandboxService(ABC):
async def wait_for_sandbox_running(
self,
sandbox_id: str,
timeout: int = 120,
timeout: int = 600,
poll_interval: int = 2,
httpx_client: httpx.AsyncClient | None = None,
) -> SandboxInfo:
@@ -106,6 +106,9 @@ class SandboxService(ABC):
sandbox = await self.get_sandbox(sandbox_id)
if sandbox is None:
raise SandboxError(f'Sandbox not found: {sandbox_id}')
_logger.info(
f'Polled sandbox status: {sandbox.status} for sandbox_id: {sandbox_id}'
)
if sandbox.status == SandboxStatus.ERROR:
raise SandboxError(f'Sandbox entered error state: {sandbox_id}')
@@ -140,10 +143,12 @@ class SandboxService(ABC):
try:
agent_server_url = self._get_agent_server_url(sandbox)
url = f'{agent_server_url.rstrip("/")}/alive'
_logger.info(f'agent server URL: {url}')
response = await httpx_client.get(url, timeout=5.0)
_logger.info(f'agent server response: {response}')
return response.is_success
except Exception as exc:
_logger.debug(
_logger.info(
f'Agent server health check failed for sandbox {sandbox.id}'
f'{f" at {url}" if url else ""}: {exc}'
)
@@ -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:61470a1-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:eae848d-python-amd64'
class SandboxSpecService(ABC):
Generated
+10 -10
View File
@@ -6235,14 +6235,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.11.4"
version = "1.11.5"
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.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
]
[package.dependencies]
@@ -6259,14 +6259,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
]
[package.dependencies]
@@ -6287,14 +6287,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.4"
version = "1.11.5"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
]
[package.dependencies]
@@ -14724,4 +14724,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "3b2bd89255226788685b3e37309c5fc9cbd0f8e4f784c48f1a36ed78f7ba0a70"
content-hash = "91cf4d77b664da6d531d557c21c0d3b200a2974b96a7bb85bb53f00960ca7ac6"
+6 -6
View File
@@ -54,9 +54,9 @@ dependencies = [
"numpy",
"openai==2.8",
"openhands-aci==0.3.2",
"openhands-agent-server==1.11.4",
"openhands-sdk==1.11.4",
"openhands-tools==1.11.4",
"openhands-agent-server==1.11.5",
"openhands-sdk==1.11.5",
"openhands-tools==1.11.5",
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
"pathspec>=0.12.1",
@@ -246,9 +246,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-sdk = "1.11.4"
openhands-agent-server = "1.11.4"
openhands-tools = "1.11.4"
openhands-sdk = "1.11.5"
openhands-agent-server = "1.11.5"
openhands-tools = "1.11.5"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
@@ -360,7 +360,9 @@ class TestDockerSandboxSpecServiceInjector:
assert len(specs) == 1
assert isinstance(specs[0], SandboxSpecInfo)
assert specs[0].id.startswith('ghcr.io/openhands/agent-server:')
assert specs[0].id.endswith('-python')
# The ID format is: ghcr.io/openhands/agent-server:<hash>-python or
# ghcr.io/openhands/agent-server:<hash>-python-<arch> (e.g., -python-amd64)
assert '-python' in specs[0].id
assert specs[0].command == ['--port', '8000']
assert 'OPENVSCODE_SERVER_ROOT' in specs[0].initial_env
assert 'OH_ENABLE_VNC' in specs[0].initial_env
@@ -119,6 +119,7 @@ def create_stored_sandbox(
user_id: str = 'test-user-123',
spec_id: str = 'test-image:latest',
created_at: datetime | None = None,
session_api_key_hash: str | None = None,
) -> StoredRemoteSandbox:
"""Helper function to create StoredRemoteSandbox for testing."""
if created_at is None:
@@ -128,6 +129,7 @@ def create_stored_sandbox(
id=sandbox_id,
created_by_user_id=user_id,
sandbox_spec_id=spec_id,
session_api_key_hash=session_api_key_hash,
created_at=created_at,
)
@@ -994,22 +996,272 @@ class TestErrorHandling:
assert result is False
class TestGetSandboxBySessionApiKey:
"""Test cases for get_sandbox_by_session_api_key functionality."""
@pytest.mark.asyncio
async def test_get_sandbox_by_session_api_key_with_hash(
self, remote_sandbox_service
):
"""Test finding sandbox by session API key using stored hash."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_hash_session_api_key,
)
# Setup
session_api_key = 'test-session-key'
expected_hash = _hash_session_api_key(session_api_key)
stored_sandbox = create_stored_sandbox(session_api_key_hash=expected_hash)
runtime_data = create_runtime_data(session_api_key=session_api_key)
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = stored_sandbox
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
result = await remote_sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
# Verify
assert result is not None
assert result.id == 'test-sandbox-123'
assert result.session_api_key == session_api_key
@pytest.mark.asyncio
async def test_get_sandbox_by_session_api_key_not_found(
self, remote_sandbox_service
):
"""Test finding sandbox when no matching hash exists and legacy fallback fails."""
# Setup - no hash match
mock_result_no_hash = MagicMock()
mock_result_no_hash.scalar_one_or_none.return_value = None
# Setup - legacy fallback: /list API fails, then no stored sandboxes
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception('API error')
remote_sandbox_service.httpx_client.request = AsyncMock(
return_value=mock_response
)
mock_result_legacy = MagicMock()
mock_result_legacy.scalars.return_value.all.return_value = []
remote_sandbox_service.db_session.execute = AsyncMock(
side_effect=[mock_result_no_hash, mock_result_legacy]
)
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
result = await remote_sandbox_service.get_sandbox_by_session_api_key(
'unknown-key'
)
# Verify
assert result is None
@pytest.mark.asyncio
async def test_get_sandbox_by_session_api_key_legacy_via_list_api(
self, remote_sandbox_service
):
"""Test legacy fallback finding sandbox via /list API and backfilling hash."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_hash_session_api_key,
)
# Setup
session_api_key = 'test-session-key'
stored_sandbox = create_stored_sandbox(
session_api_key_hash=None
) # Legacy sandbox
runtime_data = create_runtime_data(session_api_key=session_api_key)
# First call returns None (no hash match)
mock_result_no_match = MagicMock()
mock_result_no_match.scalar_one_or_none.return_value = None
# Legacy fallback: /list API returns the runtime
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {'runtimes': [runtime_data]}
remote_sandbox_service.httpx_client.request = AsyncMock(
return_value=mock_response
)
# Query for sandbox by session_id returns the stored sandbox
mock_result_sandbox = MagicMock()
mock_result_sandbox.scalar_one_or_none.return_value = stored_sandbox
remote_sandbox_service.db_session.execute = AsyncMock(
side_effect=[mock_result_no_match, mock_result_sandbox]
)
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
result = await remote_sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
# Verify
assert result is not None
assert result.id == 'test-sandbox-123'
# Verify the hash was backfilled
expected_hash = _hash_session_api_key(session_api_key)
assert stored_sandbox.session_api_key_hash == expected_hash
@pytest.mark.asyncio
async def test_get_sandbox_by_session_api_key_legacy_via_runtime_check(
self, remote_sandbox_service
):
"""Test legacy fallback checking each sandbox's runtime when /list API fails."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_hash_session_api_key,
)
# Setup
session_api_key = 'test-session-key'
stored_sandbox = create_stored_sandbox(
session_api_key_hash=None
) # Legacy sandbox
runtime_data = create_runtime_data(session_api_key=session_api_key)
# First call returns None (no hash match)
mock_result_no_match = MagicMock()
mock_result_no_match.scalar_one_or_none.return_value = None
# Legacy fallback: /list API fails
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception('API error')
remote_sandbox_service.httpx_client.request = AsyncMock(
return_value=mock_response
)
# Get all stored sandboxes returns the legacy sandbox
mock_result_all = MagicMock()
mock_result_all.scalars.return_value.all.return_value = [stored_sandbox]
remote_sandbox_service.db_session.execute = AsyncMock(
side_effect=[mock_result_no_match, mock_result_all]
)
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
result = await remote_sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
# Verify
assert result is not None
assert result.id == 'test-sandbox-123'
# Verify the hash was backfilled
expected_hash = _hash_session_api_key(session_api_key)
assert stored_sandbox.session_api_key_hash == expected_hash
@pytest.mark.asyncio
async def test_get_sandbox_by_session_api_key_runtime_error(
self, remote_sandbox_service
):
"""Test handling runtime error when getting sandbox."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_hash_session_api_key,
)
# Setup
session_api_key = 'test-session-key'
expected_hash = _hash_session_api_key(session_api_key)
stored_sandbox = create_stored_sandbox(session_api_key_hash=expected_hash)
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = stored_sandbox
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
remote_sandbox_service._get_runtime = AsyncMock(
side_effect=Exception('Runtime error')
)
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
# Execute
result = await remote_sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
# Verify - should still return sandbox info, just with None runtime
assert result is not None
assert result.id == 'test-sandbox-123'
assert result.status == SandboxStatus.MISSING # No runtime means MISSING
class TestUtilityFunctions:
"""Test cases for utility functions."""
def test_build_service_url(self):
"""Test _build_service_url function."""
def test_build_service_url_subdomain_mode(self):
"""Test _build_service_url function with subdomain-based routing."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_build_service_url,
)
# Test HTTPS URL
result = _build_service_url('https://sandbox.example.com/path', 'vscode')
# Test HTTPS URL with path (subdomain mode)
result = _build_service_url(
'https://sandbox.example.com/path', 'vscode', 'runtime-123'
)
assert result == 'https://vscode-sandbox.example.com/path'
# Test HTTP URL
result = _build_service_url('http://localhost:8000', 'work-1')
assert result == 'http://work-1-localhost:8000'
# Test HTTP URL without path (subdomain mode)
result = _build_service_url(
'http://localhost:8000', 'work-1', 'different-runtime'
)
assert result == 'http://work-1-localhost:8000/'
# Test URL with empty path (subdomain mode)
result = _build_service_url('https://sandbox.example.com', 'work-2', 'some-id')
assert result == 'https://work-2-sandbox.example.com/'
def test_build_service_url_path_mode(self):
"""Test _build_service_url function with path-based routing."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_build_service_url,
)
# Test path-based routing where URL path starts with /{runtime_id}
result = _build_service_url(
'https://sandbox.example.com/runtime-123', 'vscode', 'runtime-123'
)
assert result == 'https://sandbox.example.com/runtime-123/vscode'
# Test path-based routing with work-1
result = _build_service_url(
'https://sandbox.example.com/my-runtime-id', 'work-1', 'my-runtime-id'
)
assert result == 'https://sandbox.example.com/my-runtime-id/work-1'
# Test path-based routing with work-2
result = _build_service_url(
'http://localhost:8080/abc-xyz-123', 'work-2', 'abc-xyz-123'
)
assert result == 'http://localhost:8080/abc-xyz-123/work-2'
def test_hash_session_api_key(self):
"""Test _hash_session_api_key function."""
from openhands.app_server.sandbox.remote_sandbox_service import (
_hash_session_api_key,
)
# Test that same input always produces same hash
key = 'test-session-api-key'
hash1 = _hash_session_api_key(key)
hash2 = _hash_session_api_key(key)
assert hash1 == hash2
# Test that different inputs produce different hashes
key2 = 'another-session-api-key'
hash3 = _hash_session_api_key(key2)
assert hash1 != hash3
# Test that hash is a 64-character hex string (SHA-256)
assert len(hash1) == 64
assert all(c in '0123456789abcdef' for c in hash1)
class TestConstants:
@@ -0,0 +1,346 @@
"""Tests for webhook_router valid_sandbox and valid_conversation functions.
This module tests the webhook authentication and authorization logic.
"""
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException, status
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
class TestValidSandbox:
"""Test suite for valid_sandbox function."""
@pytest.mark.asyncio
async def test_valid_sandbox_with_valid_api_key(self):
"""Test that valid API key returns sandbox info."""
# Arrange
session_api_key = 'valid-api-key-123'
expected_sandbox = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=expected_sandbox
)
# Act
result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
# Assert
assert result == expected_sandbox
mock_sandbox_service.get_sandbox_by_session_api_key.assert_called_once_with(
session_api_key
)
@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()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await valid_sandbox(
user_context=ADMIN,
session_api_key=None,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert 'X-Session-API-Key header is required' in exc_info.value.detail
@pytest.mark.asyncio
async def test_valid_sandbox_with_invalid_api_key_raises_401(self):
"""Test that invalid API key raises 401 error."""
# Arrange
session_api_key = 'invalid-api-key'
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=None
)
# 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,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert 'Invalid session API key' in exc_info.value.detail
@pytest.mark.asyncio
async def test_valid_sandbox_with_empty_api_key_raises_401(self):
"""Test that empty API key raises 401 error (same as missing key)."""
# Arrange - empty string is falsy, so it gets rejected at the check
session_api_key = ''
mock_sandbox_service = AsyncMock()
# 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,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
assert 'X-Session-API-Key header is required' in exc_info.value.detail
# Verify the sandbox service was NOT called (rejected before lookup)
mock_sandbox_service.get_sandbox_by_session_api_key.assert_not_called()
class TestValidConversation:
"""Test suite for valid_conversation function."""
@pytest.mark.asyncio
async def test_valid_conversation_existing_returns_info(self):
"""Test that existing conversation returns info."""
# Arrange
conversation_id = uuid4()
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key='api-key',
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
expected_info = MagicMock()
expected_info.created_by_user_id = 'user-123'
mock_service = AsyncMock()
mock_service.get_app_conversation_info = AsyncMock(return_value=expected_info)
# Act
result = await valid_conversation(
conversation_id=conversation_id,
sandbox_info=sandbox_info,
app_conversation_info_service=mock_service,
)
# Assert
assert result == expected_info
@pytest.mark.asyncio
async def test_valid_conversation_new_creates_stub(self):
"""Test that non-existing conversation creates a stub."""
# Arrange
conversation_id = uuid4()
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key='api-key',
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
mock_service = AsyncMock()
mock_service.get_app_conversation_info = AsyncMock(return_value=None)
# Act
result = await valid_conversation(
conversation_id=conversation_id,
sandbox_info=sandbox_info,
app_conversation_info_service=mock_service,
)
# Assert
assert result.id == conversation_id
assert result.sandbox_id == sandbox_info.id
assert result.created_by_user_id == sandbox_info.created_by_user_id
@pytest.mark.asyncio
async def test_valid_conversation_different_user_raises_auth_error(self):
"""Test that conversation from different user raises AuthError."""
# Arrange
conversation_id = uuid4()
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key='api-key',
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
# Conversation created by different user
different_user_info = MagicMock()
different_user_info.created_by_user_id = 'different-user-id'
mock_service = AsyncMock()
mock_service.get_app_conversation_info = AsyncMock(
return_value=different_user_info
)
# Act & Assert
from openhands.app_server.errors import AuthError
with pytest.raises(AuthError):
await valid_conversation(
conversation_id=conversation_id,
sandbox_info=sandbox_info,
app_conversation_info_service=mock_service,
)
@pytest.mark.asyncio
async def test_valid_conversation_same_user_succeeds(self):
"""Test that conversation from same user succeeds."""
# Arrange
conversation_id = uuid4()
user_id = 'user-123'
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key='api-key',
created_by_user_id=user_id,
sandbox_spec_id='spec-123',
)
# Conversation created by same user
same_user_info = MagicMock()
same_user_info.created_by_user_id = user_id
mock_service = AsyncMock()
mock_service.get_app_conversation_info = AsyncMock(return_value=same_user_info)
# Act
result = await valid_conversation(
conversation_id=conversation_id,
sandbox_info=sandbox_info,
app_conversation_info_service=mock_service,
)
# Assert
assert result == same_user_info
class TestWebhookAuthenticationIntegration:
"""Integration tests for webhook authentication flow."""
@pytest.mark.asyncio
async def test_full_auth_flow_valid_key(self):
"""Test complete auth flow with valid API key."""
# Arrange
session_api_key = 'valid-api-key'
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=sandbox_info
)
conversation_info = MagicMock()
conversation_info.created_by_user_id = 'user-123'
mock_conversation_service = AsyncMock()
mock_conversation_service.get_app_conversation_info = AsyncMock(
return_value=conversation_info
)
# Act - Call valid_sandbox first
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
# Then call valid_conversation
conversation_result = await valid_conversation(
conversation_id=uuid4(),
sandbox_info=sandbox_result,
app_conversation_info_service=mock_conversation_service,
)
# Assert
assert sandbox_result.id == 'sandbox-123'
assert conversation_result.created_by_user_id == 'user-123'
@pytest.mark.asyncio
async def test_full_auth_flow_invalid_key_fails(self):
"""Test complete auth flow with invalid API key fails at sandbox validation."""
# Arrange
session_api_key = 'invalid-api-key'
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=None
)
# 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,
)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_full_auth_flow_wrong_user_fails(self):
"""Test complete auth flow with valid key but wrong user fails."""
# Arrange
session_api_key = 'valid-api-key'
sandbox_info = SandboxInfo(
id='sandbox-123',
status=SandboxStatus.RUNNING,
session_api_key=session_api_key,
created_by_user_id='user-123',
sandbox_spec_id='spec-123',
)
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=sandbox_info
)
# Conversation created by different user
different_user_info = MagicMock()
different_user_info.created_by_user_id = 'different-user'
mock_conversation_service = AsyncMock()
mock_conversation_service.get_app_conversation_info = AsyncMock(
return_value=different_user_info
)
# Act - valid_sandbox succeeds
sandbox_result = await valid_sandbox(
user_context=ADMIN,
session_api_key=session_api_key,
sandbox_service=mock_sandbox_service,
)
# But valid_conversation fails
from openhands.app_server.errors import AuthError
with pytest.raises(AuthError):
await valid_conversation(
conversation_id=uuid4(),
sandbox_info=sandbox_result,
app_conversation_info_service=mock_conversation_service,
)
Generated
+12 -12
View File
@@ -3606,7 +3606,7 @@ wheels = [
[[package]]
name = "openhands-agent-server"
version = "1.11.4"
version = "1.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
@@ -3620,9 +3620,9 @@ dependencies = [
{ name = "websockets" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/cd/49fba46297131eb4c3b4a30b187995c8f8ecd5f65b7a26522d8487d2467c/openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2", size = 70423, upload-time = "2026-02-11T16:36:47.842Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/12/546ec8e0fe22e04c5bbca36ab8c860bbdaafca29b88a382ff5ebcc06657f/openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0", size = 70384, upload-time = "2026-02-20T22:16:44.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/dc/e221607dd4c9326d5d508298b189c7357ab2b740aaf2cf09039551fa40a5/openhands_agent_server-1.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a", size = 84917, upload-time = "2026-02-11T16:36:46.856Z" },
{ url = "https://files.pythonhosted.org/packages/3a/24/6e35036b3e44878684f43acf8fa4f8e14a5578be30841458bf985fbbf566/openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e", size = 84930, upload-time = "2026-02-20T22:16:45.821Z" },
]
[[package]]
@@ -3785,9 +3785,9 @@ requires-dist = [
{ name = "numpy" },
{ name = "openai", specifier = "==2.8" },
{ name = "openhands-aci", specifier = "==0.3.2" },
{ name = "openhands-agent-server", specifier = "==1.11.4" },
{ name = "openhands-sdk", specifier = "==1.11.4" },
{ name = "openhands-tools", specifier = "==1.11.4" },
{ name = "openhands-agent-server", specifier = "==1.11.5" },
{ name = "openhands-sdk", specifier = "==1.11.5" },
{ name = "openhands-tools", specifier = "==1.11.5" },
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
{ name = "pathspec", specifier = ">=0.12.1" },
@@ -3866,7 +3866,7 @@ test = [
[[package]]
name = "openhands-sdk"
version = "1.11.4"
version = "1.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecation" },
@@ -3881,14 +3881,14 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/2a/35d8b42588930b7cc0eb86ab2973ebd57fba5138322f8216609775195280/openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313", size = 283486, upload-time = "2026-02-11T16:36:43.679Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/fc/cfb73768099be94c9f92b2e160f29d1f6d3a4dd84fea5e33a2b0984449cc/openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a", size = 295069, upload-time = "2026-02-20T22:16:47.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/e2/6da3bf3a22d0d9b6df22372473ace6fa0fc8e2322f218b62d69d4f082f80/openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5", size = 360358, upload-time = "2026-02-11T16:36:50.078Z" },
{ url = "https://files.pythonhosted.org/packages/69/6b/21df2a5b9ed756a48566d96ad491c81609af804b271c370106a4ced4ed5c/openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60", size = 377527, upload-time = "2026-02-20T22:16:48.165Z" },
]
[[package]]
name = "openhands-tools"
version = "1.11.4"
version = "1.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -3901,9 +3901,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "tom-swe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/e5/38d8147da150fccdfb7b6f9ea531e07d1a89446350fe40a2e765dcab3337/openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c", size = 93044, upload-time = "2026-02-11T16:36:48.941Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/bc/d0388c84621c3be21a011d8861cf8917371bcd42a68a87172cd246621e09/openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e", size = 101113, upload-time = "2026-02-20T22:16:50.881Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/3a/d96ac02ab3eff615f466cc2d2b8fb58ceb4f5b1e6efaa8b069460e91c1de/openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a", size = 128899, upload-time = "2026-02-11T16:36:51.132Z" },
{ url = "https://files.pythonhosted.org/packages/68/3d/dac03b376b8fea639367d6633f700d8ccba3d535cd2a8e7b17df9918ed5b/openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf", size = 138837, upload-time = "2026-02-20T22:16:43.173Z" },
]
[[package]]