mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 818f1ef6aa | |||
| 4d407aefdc | |||
| faacfd48d0 | |||
| 64ad0afff3 | |||
| aea51b3b47 | |||
| 60f921dbc0 | |||
| bcdd8bb9e2 | |||
| bf9472511c | |||
| ab0ff4c1ac | |||
| 4fcf30e76b | |||
| 27708b98aa | |||
| f3dd2024b2 | |||
| 42c3527e73 | |||
| 87a8778210 | |||
| 3640e1dadd | |||
| c9cf433fea | |||
| 8784681772 | |||
| 38e65351a5 | |||
| d8d522bb1e | |||
| d1aed4cfc1 | |||
| 36bb4d9e30 | |||
| c336a79727 | |||
| a57db2b5b2 | |||
| 929dcc39eb |
@@ -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')
|
||||
Generated
+13
-13
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user