mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
fix-basic-
...
1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d063c8ca92 |
26
enterprise/poetry.lock
generated
26
enterprise/poetry.lock
generated
@@ -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.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
|
||||
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
|
||||
{file = "openhands_agent_server-1.10.0-py3-none-any.whl", hash = "sha256:2e21076fff5e7cf9d03a3b011e2c90a6a3a46d2da3f18db9f7553ac413229c22"},
|
||||
{file = "openhands_agent_server-1.10.0.tar.gz", hash = "sha256:2062da2496a98a6c23201d086f124e02329d6c6d9d1b47be55921c084a29f55a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6126,7 +6126,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.3.0"
|
||||
version = "1.2.1"
|
||||
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.1"
|
||||
openhands-sdk = "1.11.1"
|
||||
openhands-tools = "1.11.1"
|
||||
openhands-agent-server = "1.10"
|
||||
openhands-sdk = "1.10"
|
||||
openhands-tools = "1.10"
|
||||
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.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
|
||||
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
|
||||
{file = "openhands_sdk-1.10.0-py3-none-any.whl", hash = "sha256:5c8875f2a07d7fabe3449914639572bef9003821207cb06aa237a239e964eed5"},
|
||||
{file = "openhands_sdk-1.10.0.tar.gz", hash = "sha256:93371b1af4532266ad2d225b9d7d3d711c745df31888efe643970673f62bdef9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6253,14 +6253,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.11.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
|
||||
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
|
||||
{file = "openhands_tools-1.10.0-py3-none-any.whl", hash = "sha256:1d5d2d1e34cc4ceb02c0ff1f008b06883ad48a8e7236ab8dd61ece64fbf8e2ed"},
|
||||
{file = "openhands_tools-1.10.0.tar.gz", hash = "sha256:7ed38cb13545ec2c4a35c26ece725d5b35788d30597db8b1904619c043ec1194"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@@ -18,7 +18,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
class AssessmentResult:
|
||||
"""Result of a reCAPTCHA Enterprise assessment."""
|
||||
|
||||
name: str
|
||||
score: float
|
||||
valid: bool
|
||||
action_valid: bool
|
||||
@@ -64,7 +63,6 @@ class RecaptchaService:
|
||||
user_ip: str,
|
||||
user_agent: str,
|
||||
email: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> AssessmentResult:
|
||||
"""Create a reCAPTCHA Enterprise assessment.
|
||||
|
||||
@@ -74,7 +72,6 @@ class RecaptchaService:
|
||||
user_ip: The user's IP address.
|
||||
user_agent: The user's browser user agent.
|
||||
email: Optional email for Account Defender hashing.
|
||||
user_id: Optional Keycloak user ID for logging correlation.
|
||||
|
||||
Returns:
|
||||
AssessmentResult with score, validity, and allowed status.
|
||||
@@ -103,10 +100,6 @@ class RecaptchaService:
|
||||
|
||||
response = self.client.create_assessment(request)
|
||||
|
||||
# Capture assessment name for potential annotation later
|
||||
# Format: projects/{project_id}/assessments/{assessment_id}
|
||||
assessment_name = response.name
|
||||
|
||||
token_properties = response.token_properties
|
||||
risk_analysis = response.risk_analysis
|
||||
|
||||
@@ -136,7 +129,6 @@ class RecaptchaService:
|
||||
logger.info(
|
||||
'recaptcha_assessment',
|
||||
extra={
|
||||
'assessment_name': assessment_name,
|
||||
'score': score,
|
||||
'valid': valid,
|
||||
'action_valid': action_valid,
|
||||
@@ -145,13 +137,10 @@ class RecaptchaService:
|
||||
'has_suspicious_labels': has_suspicious_labels,
|
||||
'allowed': allowed,
|
||||
'user_ip': user_ip,
|
||||
'user_id': user_id,
|
||||
'email': email,
|
||||
},
|
||||
)
|
||||
|
||||
return AssessmentResult(
|
||||
name=assessment_name,
|
||||
score=score,
|
||||
valid=valid,
|
||||
action_valid=action_valid,
|
||||
|
||||
@@ -179,9 +179,6 @@ async def keycloak_callback(
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
user = await UserStore.create_user(user_id, user_info)
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
|
||||
if not user:
|
||||
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
|
||||
@@ -222,7 +219,6 @@ async def keycloak_callback(
|
||||
user_ip=user_ip,
|
||||
user_agent=user_agent,
|
||||
email=email,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not result.allowed:
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
@@ -122,8 +121,6 @@ async def saas_get_user(
|
||||
login=(user_info.get('preferred_username') if user_info else '') or '',
|
||||
avatar_url='',
|
||||
email=user_info.get('email') if user_info else None,
|
||||
name=resolve_display_name(user_info) if user_info else None,
|
||||
company=user_info.get('company') if user_info else None,
|
||||
),
|
||||
user_info=user_info,
|
||||
)
|
||||
|
||||
@@ -24,23 +24,13 @@ from storage.user_settings import UserSettings
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# Timeout in seconds for key verification requests to LiteLLM
|
||||
KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# Timeout in seconds for BYOR key verification requests to LiteLLM
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
"""Generate the key alias for OpenHands Cloud managed keys."""
|
||||
return f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}'
|
||||
|
||||
|
||||
def get_byor_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
"""Generate the key alias for BYOR (Bring Your Own Runtime) keys."""
|
||||
return f'BYOR Key - user {keycloak_user_id}, org {org_id}'
|
||||
|
||||
|
||||
class LiteLlmManager:
|
||||
"""Manage LiteLLM interactions."""
|
||||
|
||||
@@ -89,7 +79,7 @@ class LiteLlmManager:
|
||||
client,
|
||||
keycloak_user_id,
|
||||
org_id,
|
||||
get_openhands_cloud_key_alias(keycloak_user_id, org_id),
|
||||
f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}',
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -261,7 +251,7 @@ class LiteLlmManager:
|
||||
client,
|
||||
keycloak_user_id,
|
||||
org_id,
|
||||
get_openhands_cloud_key_alias(keycloak_user_id, org_id),
|
||||
f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}',
|
||||
None,
|
||||
)
|
||||
if new_key:
|
||||
@@ -1054,7 +1044,7 @@ class LiteLlmManager:
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
timeout=KEY_VERIFICATION_TIMEOUT,
|
||||
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
) as client:
|
||||
# Make a lightweight request to verify the key
|
||||
# Using /v1/models endpoint as it's lightweight and requires authentication
|
||||
@@ -1068,7 +1058,7 @@ class LiteLlmManager:
|
||||
# Only 200 status code indicates valid key
|
||||
if response.status_code == 200:
|
||||
logger.debug(
|
||||
'Key verification successful',
|
||||
'BYOR key verification successful',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
@@ -1076,7 +1066,7 @@ class LiteLlmManager:
|
||||
# All other status codes (401, 403, 500, etc.) are treated as invalid
|
||||
# This includes authentication errors and server errors
|
||||
logger.warning(
|
||||
'Key verification failed - treating as invalid',
|
||||
'BYOR key verification failed - treating as invalid',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': response.status_code,
|
||||
@@ -1089,7 +1079,7 @@ class LiteLlmManager:
|
||||
# Any exception (timeout, network error, etc.) means we can't verify
|
||||
# Return False to trigger regeneration rather than returning potentially invalid key
|
||||
logger.warning(
|
||||
'Key verification error - treating as invalid to ensure key validity',
|
||||
'BYOR key verification error - treating as invalid to ensure key validity',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
@@ -1133,103 +1123,6 @@ class LiteLlmManager:
|
||||
'key_spend': key_info.get('spend'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _get_all_keys_for_user(
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
) -> list[dict]:
|
||||
"""Get all keys for a user from LiteLLM.
|
||||
|
||||
Returns a list of key info dictionaries containing:
|
||||
- token: the key value (hashed or partial)
|
||||
- key_alias: the alias for the key
|
||||
- key_name: the name of the key
|
||||
- spend: the amount spent on this key
|
||||
- max_budget: the max budget for this key
|
||||
- team_id: the team the key belongs to
|
||||
- metadata: any metadata associated with the key
|
||||
|
||||
Returns an empty list if no keys found or on error.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return []
|
||||
|
||||
try:
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={keycloak_user_id}',
|
||||
headers={'x-goog-api-key': LITE_LLM_API_KEY},
|
||||
)
|
||||
response.raise_for_status()
|
||||
user_json = response.json()
|
||||
# The user/info endpoint returns keys in the 'keys' field
|
||||
return user_json.get('keys', [])
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'LiteLlmManager:_get_all_keys_for_user:error',
|
||||
extra={
|
||||
'user_id': keycloak_user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def _verify_existing_key(
|
||||
client: httpx.AsyncClient,
|
||||
key_value: str,
|
||||
keycloak_user_id: str,
|
||||
org_id: str,
|
||||
openhands_type: bool = False,
|
||||
) -> bool:
|
||||
"""Check if an existing key exists for the user/org in LiteLLM.
|
||||
|
||||
Verifies the provided key_value matches a key registered in LiteLLM for
|
||||
the given user and organization. For openhands_type=True, looks for keys
|
||||
with metadata type='openhands' and matching team_id. For openhands_type=False,
|
||||
looks for keys with matching alias and team_id.
|
||||
|
||||
Returns True if the key is found and valid, False otherwise.
|
||||
"""
|
||||
found = False
|
||||
keys = await LiteLlmManager._get_all_keys_for_user(client, keycloak_user_id)
|
||||
for key_info in keys:
|
||||
metadata = key_info.get('metadata') or {}
|
||||
team_id = key_info.get('team_id')
|
||||
key_alias = key_info.get('key_alias')
|
||||
token = None
|
||||
if (
|
||||
openhands_type
|
||||
and metadata.get('type') == 'openhands'
|
||||
and team_id == org_id
|
||||
):
|
||||
# Found an existing OpenHands key for this org
|
||||
key_name = key_info.get('key_name')
|
||||
token = key_name[-4:] if key_name else None # last 4 digits of key
|
||||
if token and key_value.endswith(
|
||||
token
|
||||
): # check if this is our current key
|
||||
found = True
|
||||
break
|
||||
if (
|
||||
not openhands_type
|
||||
and team_id == org_id
|
||||
and (
|
||||
key_alias == get_openhands_cloud_key_alias(keycloak_user_id, org_id)
|
||||
or key_alias == get_byor_key_alias(keycloak_user_id, org_id)
|
||||
)
|
||||
):
|
||||
# Found an existing key for this org (regardless of type)
|
||||
key_name = key_info.get('key_name')
|
||||
token = key_name[-4:] if key_name else None # last 4 digits of key
|
||||
if token and key_value.endswith(
|
||||
token
|
||||
): # check if this is our current key
|
||||
found = True
|
||||
break
|
||||
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
async def _delete_key_by_alias(
|
||||
client: httpx.AsyncClient,
|
||||
@@ -1327,8 +1220,6 @@ class LiteLlmManager:
|
||||
update_user_in_team = staticmethod(with_http_client(_update_user_in_team))
|
||||
generate_key = staticmethod(with_http_client(_generate_key))
|
||||
get_key_info = staticmethod(with_http_client(_get_key_info))
|
||||
verify_existing_key = staticmethod(with_http_client(_verify_existing_key))
|
||||
delete_key = staticmethod(with_http_client(_delete_key))
|
||||
get_user_keys = staticmethod(with_http_client(_get_user_keys))
|
||||
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
|
||||
update_user_keys = staticmethod(with_http_client(_update_user_keys))
|
||||
|
||||
@@ -5,8 +5,7 @@ Store class for managing organization-member relationships.
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
@@ -39,7 +38,7 @@ class OrgMemberStore:
|
||||
return org_member
|
||||
|
||||
@staticmethod
|
||||
def get_org_member(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
|
||||
def get_org_member(org_id: UUID, user_id: int) -> Optional[OrgMember]:
|
||||
"""Get organization-user relationship."""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
@@ -49,18 +48,7 @@ class OrgMemberStore:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_org_member_async(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
|
||||
"""Get organization-user relationship."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(
|
||||
OrgMember.org_id == org_id, OrgMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_orgs(user_id: UUID) -> list[OrgMember]:
|
||||
def get_user_orgs(user_id: int) -> list[OrgMember]:
|
||||
"""Get all organizations for a user."""
|
||||
with session_maker() as session:
|
||||
return session.query(OrgMember).filter(OrgMember.user_id == user_id).all()
|
||||
@@ -80,7 +68,7 @@ class OrgMemberStore:
|
||||
|
||||
@staticmethod
|
||||
def update_user_role_in_org(
|
||||
org_id: UUID, user_id: UUID, role_id: int, status: Optional[str] = None
|
||||
org_id: UUID, user_id: int, role_id: int, status: Optional[str] = None
|
||||
) -> Optional[OrgMember]:
|
||||
"""Update user's role in an organization."""
|
||||
with session_maker() as session:
|
||||
@@ -102,7 +90,7 @@ class OrgMemberStore:
|
||||
return org_member
|
||||
|
||||
@staticmethod
|
||||
def remove_user_from_org(org_id: UUID, user_id: UUID) -> bool:
|
||||
def remove_user_from_org(org_id: UUID, user_id: int) -> bool:
|
||||
"""Remove a user from an organization."""
|
||||
with session_maker() as session:
|
||||
org_member = (
|
||||
|
||||
@@ -8,11 +8,10 @@ from dataclasses import dataclass
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import SecretStr
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from sqlalchemy.orm import joinedload, sessionmaker
|
||||
from storage.database import session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_store import OrgStore
|
||||
@@ -106,13 +105,13 @@ class SaasSettingsStore(SettingsStore):
|
||||
},
|
||||
}
|
||||
kwargs['llm_api_key'] = org_member.llm_api_key
|
||||
if org_member.max_iterations is not None:
|
||||
if org_member.max_iterations:
|
||||
kwargs['max_iterations'] = org_member.max_iterations
|
||||
if org_member.llm_model is not None:
|
||||
if org_member.llm_model:
|
||||
kwargs['llm_model'] = org_member.llm_model
|
||||
if org_member.llm_api_key_for_byor is not None:
|
||||
if org_member.llm_api_key_for_byor:
|
||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
||||
if org_member.llm_base_url is not None:
|
||||
if org_member.llm_base_url:
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
@@ -144,27 +143,23 @@ class SaasSettingsStore(SettingsStore):
|
||||
return None
|
||||
|
||||
org_id = user.current_org_id
|
||||
|
||||
org_member: OrgMember = None
|
||||
# Check if provider is OpenHands and generate API key if needed
|
||||
if self._is_openhands_provider(item):
|
||||
await self._ensure_openhands_api_key(item, str(org_id))
|
||||
org_member = None
|
||||
for om in user.org_members:
|
||||
if om.org_id == org_id:
|
||||
org_member = om
|
||||
break
|
||||
if not org_member or not org_member.llm_api_key:
|
||||
return None
|
||||
|
||||
org: Org = session.query(Org).filter(Org.id == org_id).first()
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
if not org:
|
||||
logger.error(
|
||||
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if we need to generate an LLM key.
|
||||
is_openhands_provider = self._is_openhands_provider(item)
|
||||
if is_openhands_provider or item.llm_base_url == LITE_LLM_API_URL:
|
||||
await self._ensure_api_key(item, str(org_id), is_openhands_provider)
|
||||
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
for model in (user, org, org_member):
|
||||
for key, value in kwargs.items():
|
||||
@@ -232,49 +227,37 @@ class SaasSettingsStore(SettingsStore):
|
||||
"""Check if the settings use the OpenHands provider."""
|
||||
return bool(item.llm_model and item.llm_model.startswith('openhands/'))
|
||||
|
||||
async def _ensure_api_key(
|
||||
self, item: Settings, org_id: str, openhands_type: bool = False
|
||||
) -> None:
|
||||
async def _ensure_openhands_api_key(self, item: Settings, org_id: str) -> None:
|
||||
"""Generate and set the OpenHands API key for the given settings.
|
||||
|
||||
First checks if an existing key exists for the user and verifies it
|
||||
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
|
||||
First checks if an existing key exists for the user and reuses it
|
||||
if found. Otherwise, generates a new key.
|
||||
"""
|
||||
# Check if user already has keys in LiteLLM
|
||||
existing_keys = await LiteLlmManager.get_user_keys(self.user_id)
|
||||
if existing_keys:
|
||||
logger.info(
|
||||
'saas_settings_store:store:user_already_has_keys',
|
||||
extra={'user_id': self.user_id, 'key_count': len(existing_keys)},
|
||||
)
|
||||
return
|
||||
|
||||
# First, check if our current key is valid
|
||||
if item.llm_api_key and not await LiteLlmManager.verify_existing_key(
|
||||
item.llm_api_key.get_secret_value(),
|
||||
# Generate new key only if none exists
|
||||
generated_key = await LiteLlmManager.generate_key(
|
||||
self.user_id,
|
||||
org_id,
|
||||
openhands_type=openhands_type,
|
||||
):
|
||||
generated_key = None
|
||||
if openhands_type:
|
||||
generated_key = await LiteLlmManager.generate_key(
|
||||
self.user_id,
|
||||
org_id,
|
||||
None,
|
||||
{'type': 'openhands'},
|
||||
)
|
||||
else:
|
||||
# Must delete any existing key with the same alias first
|
||||
key_alias = get_openhands_cloud_key_alias(self.user_id, org_id)
|
||||
await LiteLlmManager.delete_key_by_alias(key_alias=key_alias)
|
||||
generated_key = await LiteLlmManager.generate_key(
|
||||
self.user_id,
|
||||
org_id,
|
||||
key_alias,
|
||||
None,
|
||||
)
|
||||
None,
|
||||
{'type': 'openhands'},
|
||||
)
|
||||
|
||||
if generated_key:
|
||||
item.llm_api_key = SecretStr(generated_key)
|
||||
logger.info(
|
||||
'saas_settings_store:store:generated_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'saas_settings_store:store:failed_to_generate_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
if generated_key:
|
||||
item.llm_api_key = SecretStr(generated_key)
|
||||
logger.info(
|
||||
'saas_settings_store:store:generated_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'saas_settings_store:store:failed_to_generate_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ from storage.org_member import OrgMember
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
@@ -54,8 +53,7 @@ class UserStore:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_name=resolve_display_name(user_info)
|
||||
or user_info.get('preferred_username', ''),
|
||||
contact_name=user_info['preferred_username'],
|
||||
contact_email=user_info['email'],
|
||||
v1_enabled=True,
|
||||
)
|
||||
@@ -177,8 +175,7 @@ class UserStore:
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
org_version=user_settings.user_version,
|
||||
contact_name=resolve_display_name(user_info)
|
||||
or user_info.get('username', ''),
|
||||
contact_name=user_info['username'],
|
||||
contact_email=user_info['email'],
|
||||
)
|
||||
session.add(org)
|
||||
@@ -759,49 +756,6 @@ class UserStore:
|
||||
with session_maker() as session:
|
||||
return session.query(User).all()
|
||||
|
||||
@staticmethod
|
||||
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
|
||||
"""Update contact_name on the personal org if it still has a username-style value.
|
||||
|
||||
Called during login to gradually fix existing users whose contact_name
|
||||
was stored as their username (before the resolve_display_name fix).
|
||||
Preserves custom values that were set via the PATCH endpoint.
|
||||
"""
|
||||
real_name = resolve_display_name(user_info)
|
||||
if not real_name:
|
||||
logger.debug(
|
||||
'backfill_contact_name:no_real_name',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return
|
||||
|
||||
preferred_username = user_info.get('preferred_username', '')
|
||||
username = user_info.get('username', '')
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Org).filter(Org.id == uuid.UUID(user_id))
|
||||
)
|
||||
org = result.scalars().first()
|
||||
if not org:
|
||||
logger.debug(
|
||||
'backfill_contact_name:org_not_found',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return
|
||||
|
||||
if org.contact_name in (preferred_username, username):
|
||||
logger.info(
|
||||
'backfill_contact_name:updated',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'old': org.contact_name,
|
||||
'new': real_name,
|
||||
},
|
||||
)
|
||||
org.contact_name = real_name
|
||||
await session.commit()
|
||||
|
||||
# Prevent circular imports
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
@@ -153,7 +153,6 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = False
|
||||
@@ -189,7 +188,6 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
@@ -261,7 +259,6 @@ async def test_keycloak_callback_email_not_verified(mock_request):
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -309,7 +306,6 @@ async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -351,7 +347,6 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
@@ -586,7 +581,6 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = True
|
||||
@@ -650,7 +644,6 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
@@ -714,7 +707,6 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = False
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
@@ -776,7 +768,6 @@ async def test_keycloak_callback_missing_email(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
|
||||
@@ -822,7 +813,6 @@ async def test_keycloak_callback_duplicate_email_detected(mock_request):
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -867,7 +857,6 @@ async def test_keycloak_callback_duplicate_email_deletion_fails(mock_request):
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
@@ -925,7 +914,6 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -983,7 +971,6 @@ async def test_keycloak_callback_no_duplicate_email(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1044,7 +1031,6 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1201,7 +1187,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1266,7 +1251,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
@@ -1349,7 +1333,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1437,7 +1420,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1522,7 +1504,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1606,7 +1587,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1687,7 +1667,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1754,7 +1733,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1827,7 +1805,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.accepted_tos = '2025-01-01'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -1898,7 +1875,6 @@ class TestKeycloakCallbackRecaptcha:
|
||||
mock_user.current_org_id = 'test_org_id'
|
||||
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.create_user = AsyncMock(return_value=mock_user)
|
||||
mock_user_store.backfill_contact_name = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Tests for the resolve_display_name helper.
|
||||
|
||||
The resolve_display_name helper extracts the best available display name from
|
||||
Keycloak user_info claims. It is used by both the /api/user/info fallback path
|
||||
and the user_store create/migrate paths to avoid duplicating name-resolution logic.
|
||||
|
||||
The fallback chain is: name → given_name + family_name → None.
|
||||
It intentionally does NOT fall back to preferred_username/username — callers
|
||||
that need a guaranteed non-None value handle that separately, because the
|
||||
/api/user/info route should return name=None when no real name is available.
|
||||
"""
|
||||
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
|
||||
class TestResolveDisplayName:
|
||||
"""Test resolve_display_name with various Keycloak claim combinations."""
|
||||
|
||||
def test_returns_name_when_present(self):
|
||||
"""When user_info has a 'name' claim, use it directly."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'name': 'Jane Doe',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Jane Doe'
|
||||
|
||||
def test_combines_given_and_family_name_when_name_absent(self):
|
||||
"""When 'name' is missing, combine given_name + family_name."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Jane Doe'
|
||||
|
||||
def test_uses_given_name_only_when_family_name_absent(self):
|
||||
"""When only given_name is available, use it alone."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'given_name': 'Jane',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Jane'
|
||||
|
||||
def test_uses_family_name_only_when_given_name_absent(self):
|
||||
"""When only family_name is available, use it alone."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'family_name': 'Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Doe'
|
||||
|
||||
def test_returns_none_when_no_name_claims(self):
|
||||
"""When no name claims exist at all, return None."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
}
|
||||
assert resolve_display_name(user_info) is None
|
||||
|
||||
def test_returns_none_for_empty_name(self):
|
||||
"""When 'name' is an empty string, treat it as absent."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'name': '',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) is None
|
||||
|
||||
def test_returns_none_for_whitespace_only_name(self):
|
||||
"""When 'name' is whitespace only, treat it as absent."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'name': ' ',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) is None
|
||||
|
||||
def test_returns_none_for_empty_given_and_family_names(self):
|
||||
"""When given_name and family_name are both empty strings, return None."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'given_name': '',
|
||||
'family_name': '',
|
||||
'preferred_username': 'j.doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) is None
|
||||
|
||||
def test_returns_none_for_empty_dict(self):
|
||||
"""An empty user_info dict returns None."""
|
||||
assert resolve_display_name({}) is None
|
||||
|
||||
def test_strips_whitespace_from_combined_name(self):
|
||||
"""Whitespace around given_name/family_name is stripped."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'given_name': ' Jane ',
|
||||
'family_name': ' Doe ',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Jane Doe'
|
||||
|
||||
def test_name_claim_takes_priority_over_given_family(self):
|
||||
"""When both 'name' and given/family are present, 'name' wins."""
|
||||
user_info = {
|
||||
'sub': '123',
|
||||
'name': 'Dr. Jane Doe',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Doe',
|
||||
}
|
||||
assert resolve_display_name(user_info) == 'Dr. Jane Doe'
|
||||
@@ -11,11 +11,7 @@ from pydantic import SecretStr
|
||||
from server.constants import (
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from storage.lite_llm_manager import (
|
||||
LiteLlmManager,
|
||||
get_byor_key_alias,
|
||||
get_openhands_cloud_key_alias,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.settings import Settings
|
||||
@@ -1551,321 +1547,3 @@ class TestLiteLlmManager:
|
||||
# making any LiteLLM calls
|
||||
assert result is not None
|
||||
assert result.agent == 'TestAgent'
|
||||
|
||||
|
||||
class TestGetAllKeysForUser:
|
||||
"""Test cases for _get_all_keys_for_user method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_keys_missing_config(self):
|
||||
"""Test _get_all_keys_for_user when LiteLLM config is missing."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
|
||||
result = await LiteLlmManager._get_all_keys_for_user(
|
||||
mock_client, 'test-user-id'
|
||||
)
|
||||
assert result == []
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_keys_success(self):
|
||||
"""Test _get_all_keys_for_user returns keys on success."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'keys': [
|
||||
{
|
||||
'key_name': 'sk-test1234',
|
||||
'key_alias': 'test-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
},
|
||||
{
|
||||
'key_name': 'sk-test5678',
|
||||
'key_alias': 'another-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': None,
|
||||
},
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
result = await LiteLlmManager._get_all_keys_for_user(
|
||||
mock_client, 'test-user-id'
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['key_name'] == 'sk-test1234'
|
||||
assert result[1]['key_name'] == 'sk-test5678'
|
||||
|
||||
# Verify API key header is included
|
||||
mock_client.get.assert_called_once()
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs.kwargs['headers'] == {
|
||||
'x-goog-api-key': 'test-api-key'
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_keys_empty_response(self):
|
||||
"""Test _get_all_keys_for_user returns empty list when user has no keys."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'keys': []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
result = await LiteLlmManager._get_all_keys_for_user(
|
||||
mock_client, 'test-user-id'
|
||||
)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_keys_api_error(self):
|
||||
"""Test _get_all_keys_for_user handles API errors gracefully."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.side_effect = Exception('API Error')
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
result = await LiteLlmManager._get_all_keys_for_user(
|
||||
mock_client, 'test-user-id'
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestVerifyExistingKey:
|
||||
"""Test cases for _verify_existing_key method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_openhands_type_found(self):
|
||||
"""Test _verify_existing_key finds matching OpenHands key."""
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': 'sk-test1234',
|
||||
'key_alias': 'some-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
# Key ending with '1234' should match 'sk-test1234'
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'my-key-ending-with-1234',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_openhands_type_not_found(self):
|
||||
"""Test _verify_existing_key returns False when key doesn't match."""
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': 'sk-test1234',
|
||||
'key_alias': 'some-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
# Key ending with '5678' should NOT match 'sk-test1234'
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'my-key-ending-with-5678',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_by_alias_openhands_cloud(self):
|
||||
"""Test _verify_existing_key finds key by OpenHands Cloud alias."""
|
||||
user_id = 'test-user-id'
|
||||
org_id = 'test-org'
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': 'sk-testABCD',
|
||||
'key_alias': get_openhands_cloud_key_alias(user_id, org_id),
|
||||
'team_id': org_id,
|
||||
'metadata': None,
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'my-key-ending-with-ABCD',
|
||||
user_id,
|
||||
org_id,
|
||||
openhands_type=False,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_by_alias_byor(self):
|
||||
"""Test _verify_existing_key finds key by BYOR alias."""
|
||||
user_id = 'test-user-id'
|
||||
org_id = 'test-org'
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': 'sk-testXYZW',
|
||||
'key_alias': get_byor_key_alias(user_id, org_id),
|
||||
'team_id': org_id,
|
||||
'metadata': None,
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'my-key-ending-with-XYZW',
|
||||
user_id,
|
||||
org_id,
|
||||
openhands_type=False,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_wrong_team(self):
|
||||
"""Test _verify_existing_key returns False for wrong team_id."""
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': 'sk-test1234',
|
||||
'key_alias': 'some-alias',
|
||||
'team_id': 'different-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'my-key-ending-with-1234',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_no_keys(self):
|
||||
"""Test _verify_existing_key returns False when user has no keys."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = []
|
||||
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'some-key-value',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_handles_none_key_name(self):
|
||||
"""Test _verify_existing_key handles None key_name gracefully."""
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': None,
|
||||
'key_alias': 'some-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
# Should not raise TypeError, should return False
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'some-key-value',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_existing_key_handles_empty_key_name(self):
|
||||
"""Test _verify_existing_key handles empty key_name gracefully."""
|
||||
mock_keys = [
|
||||
{
|
||||
'key_name': '',
|
||||
'key_alias': 'some-alias',
|
||||
'team_id': 'test-org',
|
||||
'metadata': {'type': 'openhands'},
|
||||
}
|
||||
]
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
with patch.object(
|
||||
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
|
||||
) as mock_get_keys:
|
||||
mock_get_keys.return_value = mock_keys
|
||||
|
||||
# Should not raise error, should return False
|
||||
result = await LiteLlmManager._verify_existing_key(
|
||||
mock_client,
|
||||
'some-key-value',
|
||||
'test-user-id',
|
||||
'test-org',
|
||||
openhands_type=True,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
@@ -94,7 +94,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that assessment allows request when score is above threshold."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/abc123'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -111,7 +110,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, AssessmentResult)
|
||||
assert result.name == 'projects/test-project/assessments/abc123'
|
||||
assert result.allowed is True
|
||||
assert result.score == 0.9
|
||||
assert result.valid is True
|
||||
@@ -124,7 +122,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that assessment blocks request when score is below threshold."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/def456'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.2
|
||||
@@ -149,7 +146,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that assessment blocks request when token is invalid."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/ghi789'
|
||||
mock_response.token_properties.valid = False
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -174,7 +170,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that assessment blocks request when action doesn't match."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/jkl012'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'SIGNUP'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -199,7 +194,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that email is included in user_info when provided."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/mno345'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -229,7 +223,6 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
"""Test that user_info is not included when email is None."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/pqr678'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -255,13 +248,10 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
# If user_info exists, verify account_id is empty (not set)
|
||||
assert not assessment.event.user_info.account_id
|
||||
|
||||
def test_should_log_assessment_details_including_name(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that assessment details including assessment name are logged."""
|
||||
def test_should_log_assessment_details(self, recaptcha_service, mock_gcp_client):
|
||||
"""Test that assessment details are logged."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/stu901'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
@@ -281,73 +271,11 @@ class TestRecaptchaServiceCreateAssessment:
|
||||
mock_logger.info.assert_called_once()
|
||||
call_kwargs = mock_logger.info.call_args
|
||||
assert call_kwargs[0][0] == 'recaptcha_assessment'
|
||||
assert (
|
||||
call_kwargs[1]['extra']['assessment_name']
|
||||
== 'projects/test-project/assessments/stu901'
|
||||
)
|
||||
assert call_kwargs[1]['extra']['score'] == 0.9
|
||||
assert call_kwargs[1]['extra']['valid'] is True
|
||||
assert call_kwargs[1]['extra']['action_valid'] is True
|
||||
assert call_kwargs[1]['extra']['user_ip'] == '192.168.1.1'
|
||||
|
||||
def test_should_log_user_id_and_email_when_provided(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that user_id and email are included in log when provided."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/xyz123'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
with patch('server.auth.recaptcha_service.logger') as mock_logger:
|
||||
# Act
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
email='test@example.com',
|
||||
user_id='keycloak-user-123',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.info.assert_called_once()
|
||||
call_kwargs = mock_logger.info.call_args
|
||||
assert call_kwargs[1]['extra']['user_id'] == 'keycloak-user-123'
|
||||
assert call_kwargs[1]['extra']['email'] == 'test@example.com'
|
||||
|
||||
def test_should_log_none_for_user_id_and_email_when_not_provided(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that user_id and email are logged as None when not provided."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.name = 'projects/test-project/assessments/abc456'
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
with patch('server.auth.recaptcha_service.logger') as mock_logger:
|
||||
# Act
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.info.assert_called_once()
|
||||
call_kwargs = mock_logger.info.call_args
|
||||
assert call_kwargs[1]['extra']['user_id'] is None
|
||||
assert call_kwargs[1]['extra']['email'] is None
|
||||
|
||||
def test_should_raise_exception_when_gcp_client_fails(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.data_models.settings import Settings as DataSettings
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine'), patch('storage.database.a_engine'):
|
||||
@@ -177,53 +176,3 @@ async def test_encryption(settings_store):
|
||||
# But we should be able to decrypt it when loading
|
||||
loaded_settings = await settings_store.load()
|
||||
assert loaded_settings.llm_api_key.get_secret_value() == 'secret_key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_api_key_keeps_valid_key(mock_config):
|
||||
"""When the existing key is valid, it should be kept unchanged."""
|
||||
store = SaasSettingsStore('test-user-id-123', MagicMock(), mock_config)
|
||||
existing_key = 'sk-existing-key'
|
||||
item = DataSettings(
|
||||
llm_model='openhands/gpt-4', llm_api_key=SecretStr(existing_key)
|
||||
)
|
||||
|
||||
with patch(
|
||||
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
await store._ensure_api_key(item, 'org-123', openhands_type=True)
|
||||
|
||||
# Key should remain unchanged when it's valid
|
||||
assert item.llm_api_key is not None
|
||||
assert item.llm_api_key.get_secret_value() == existing_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_api_key_generates_new_key_when_verification_fails(
|
||||
mock_config,
|
||||
):
|
||||
"""When verification fails, a new key should be generated."""
|
||||
store = SaasSettingsStore('test-user-id-123', MagicMock(), mock_config)
|
||||
new_key = 'sk-new-key'
|
||||
item = DataSettings(
|
||||
llm_model='openhands/gpt-4', llm_api_key=SecretStr('sk-invalid-key')
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
'storage.saas_settings_store.LiteLlmManager.generate_key',
|
||||
new_callable=AsyncMock,
|
||||
return_value=new_key,
|
||||
),
|
||||
):
|
||||
await store._ensure_api_key(item, 'org-123', openhands_type=True)
|
||||
|
||||
assert item.llm_api_key is not None
|
||||
assert item.llm_api_key.get_secret_value() == new_key
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests for the fallback User path in the /api/user/info endpoint.
|
||||
|
||||
When a user authenticates via Keycloak without provider tokens (e.g., SAML, enterprise SSO),
|
||||
the endpoint constructs a User from OIDC claims. These tests verify that name and company
|
||||
fields are correctly populated from Keycloak claims in this fallback path.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.service_types import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_token_manager():
|
||||
"""Mock the token_manager used by user.py routes."""
|
||||
with patch('server.routes.user.token_manager') as mock_tm:
|
||||
yield mock_tm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_check_idp():
|
||||
"""Mock _check_idp to pass through the default_value (the fallback User).
|
||||
|
||||
This isolates the test to just the User construction logic in saas_get_user,
|
||||
without needing to set up Keycloak IDP token checks.
|
||||
"""
|
||||
with patch('server.routes.user._check_idp') as mock_fn:
|
||||
# Return the default_value argument that was passed to _check_idp
|
||||
mock_fn.side_effect = lambda **kwargs: kwargs.get('default_value')
|
||||
yield mock_fn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_includes_name_from_name_claim(
|
||||
mock_token_manager, mock_check_idp
|
||||
):
|
||||
"""When Keycloak provides a 'name' claim, the fallback User should include it."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'name': 'Jane Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.name == 'Jane Doe'
|
||||
assert result.email == 'jane@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_combines_given_and_family_name(
|
||||
mock_token_manager, mock_check_idp
|
||||
):
|
||||
"""When 'name' is absent, combine given_name + family_name."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.name == 'Jane Doe'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_name_is_none_when_no_name_claims(
|
||||
mock_token_manager, mock_check_idp
|
||||
):
|
||||
"""When no name claims exist, name should be None."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.name is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_includes_company_claim(mock_token_manager, mock_check_idp):
|
||||
"""When Keycloak provides a 'company' claim, include it in the User."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'name': 'Jane Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
'company': 'Acme Corp',
|
||||
}
|
||||
)
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.company == 'Acme Corp'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_user_company_is_none_when_absent(
|
||||
mock_token_manager, mock_check_idp
|
||||
):
|
||||
"""When 'company' is not in Keycloak claims, company should be None."""
|
||||
from server.routes.user import saas_get_user
|
||||
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': '248289761001',
|
||||
'name': 'Jane Doe',
|
||||
'preferred_username': 'j.doe',
|
||||
'email': 'jane@example.com',
|
||||
}
|
||||
)
|
||||
|
||||
result = await saas_get_user(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('test-token'),
|
||||
user_id='248289761001',
|
||||
)
|
||||
|
||||
assert isinstance(result, User)
|
||||
assert result.company is None
|
||||
@@ -1,15 +1,15 @@
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.orm import configure_mappers
|
||||
|
||||
# Database connection is lazy (no module-level engines), so no patching needed
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
# Mock the database module before importing UserStore
|
||||
with patch('storage.database.engine'), patch('storage.database.a_engine'):
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from sqlalchemy.orm import configure_mappers
|
||||
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
@@ -162,363 +162,3 @@ def test_get_kwargs_from_settings():
|
||||
assert 'enable_sound_notifications' in kwargs
|
||||
# Should not include fields that don't exist in User model
|
||||
assert 'llm_api_key' not in kwargs
|
||||
|
||||
|
||||
# --- Tests for contact_name resolution in migrate_user() ---
|
||||
# migrate_user() should use resolve_display_name() to populate contact_name
|
||||
# from Keycloak name claims, falling back to username only when no real name
|
||||
# is available. This mirrors the create_user() fix and ensures migrated Org
|
||||
# records also store the user's actual display name.
|
||||
|
||||
|
||||
class _StopAfterOrgCreation(Exception):
|
||||
"""Halt migrate_user() after Org creation for contact_name inspection."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_user_contact_name_uses_name_claim():
|
||||
"""When user_info has a 'name' claim, migrate_user() should use it for contact_name."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'username': 'jdoe',
|
||||
'email': 'jdoe@example.com',
|
||||
'name': 'John Doe',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.user_version = 1
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch(
|
||||
'storage.user_store.decrypt_legacy_model',
|
||||
return_value={'keycloak_user_id': user_id},
|
||||
),
|
||||
patch('storage.user_store.UserSettings'),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=_StopAfterOrgCreation,
|
||||
),
|
||||
):
|
||||
with pytest.raises(_StopAfterOrgCreation):
|
||||
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
|
||||
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'John Doe'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_user_contact_name_uses_given_family_names():
|
||||
"""When only given_name and family_name are present, migrate_user() should combine them."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'username': 'jsmith',
|
||||
'email': 'jsmith@example.com',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Smith',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.user_version = 1
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch(
|
||||
'storage.user_store.decrypt_legacy_model',
|
||||
return_value={'keycloak_user_id': user_id},
|
||||
),
|
||||
patch('storage.user_store.UserSettings'),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=_StopAfterOrgCreation,
|
||||
),
|
||||
):
|
||||
with pytest.raises(_StopAfterOrgCreation):
|
||||
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
|
||||
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'Jane Smith'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_user_contact_name_falls_back_to_username():
|
||||
"""When no name claims exist, migrate_user() should fall back to username."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'username': 'jdoe',
|
||||
'email': 'jdoe@example.com',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.user_version = 1
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch(
|
||||
'storage.user_store.decrypt_legacy_model',
|
||||
return_value={'keycloak_user_id': user_id},
|
||||
),
|
||||
patch('storage.user_store.UserSettings'),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=_StopAfterOrgCreation,
|
||||
),
|
||||
):
|
||||
with pytest.raises(_StopAfterOrgCreation):
|
||||
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
|
||||
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'jdoe'
|
||||
|
||||
|
||||
# --- Tests for contact_name resolution in create_user() ---
|
||||
# create_user() should use resolve_display_name() to populate contact_name
|
||||
# from Keycloak name claims, falling back to preferred_username only when
|
||||
# no real name is available. This ensures Org records store the user's
|
||||
# actual display name for use in UI and analytics.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_contact_name_uses_name_claim():
|
||||
"""When user_info has a 'name' claim, create_user() should use it for contact_name."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'preferred_username': 'jdoe',
|
||||
'email': 'jdoe@example.com',
|
||||
'name': 'John Doe',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch.object(
|
||||
UserStore,
|
||||
'create_default_settings',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await UserStore.create_user(user_id, user_info)
|
||||
|
||||
assert result is None # create_default_settings returned None
|
||||
# The Org should have been added to the session with the real display name
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'John Doe'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_contact_name_uses_given_family_names():
|
||||
"""When only given_name and family_name are present, create_user() should combine them."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'preferred_username': 'jsmith',
|
||||
'email': 'jsmith@example.com',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Smith',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch.object(
|
||||
UserStore,
|
||||
'create_default_settings',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await UserStore.create_user(user_id, user_info)
|
||||
|
||||
assert result is None
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'Jane Smith'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_contact_name_falls_back_to_username():
|
||||
"""When no name claims exist, create_user() should fall back to preferred_username."""
|
||||
user_id = str(uuid.uuid4())
|
||||
user_info = {
|
||||
'preferred_username': 'jdoe',
|
||||
'email': 'jdoe@example.com',
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
|
||||
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch('storage.user_store.session_maker', mock_sm),
|
||||
patch.object(
|
||||
UserStore,
|
||||
'create_default_settings',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await UserStore.create_user(user_id, user_info)
|
||||
|
||||
assert result is None
|
||||
org = mock_session.add.call_args_list[0][0][0]
|
||||
assert isinstance(org, Org)
|
||||
assert org.contact_name == 'jdoe'
|
||||
|
||||
|
||||
# --- Tests for backfill_contact_name on login ---
|
||||
# Existing users created before the resolve_display_name fix may have
|
||||
# username-style values in contact_name. The backfill updates these to
|
||||
# the user's real display name when they next log in, but preserves
|
||||
# custom values set via the PATCH endpoint.
|
||||
|
||||
|
||||
def _wrap_sync_as_async_session_maker(sync_sm):
|
||||
"""Wrap a sync session_maker so it can be used in place of a_session_maker."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _async_sm():
|
||||
session = sync_sm()
|
||||
try:
|
||||
|
||||
class _AsyncWrapper:
|
||||
async def execute(self, *args, **kwargs):
|
||||
return session.execute(*args, **kwargs)
|
||||
|
||||
async def commit(self):
|
||||
session.commit()
|
||||
|
||||
yield _AsyncWrapper()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return _async_sm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_contact_name_updates_when_matches_preferred_username(
|
||||
session_maker,
|
||||
):
|
||||
"""When contact_name matches preferred_username and a real name is available, update it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
# Create org with username-style contact_name (as create_user used to store)
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_name='jdoe',
|
||||
contact_email='jdoe@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'preferred_username': 'jdoe',
|
||||
'name': 'John Doe',
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
|
||||
assert org.contact_name == 'John Doe'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_contact_name_updates_when_matches_username(session_maker):
|
||||
"""When contact_name matches username (migrate_user legacy) and a real name is available, update it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
# Create org with username-style contact_name (as migrate_user used to store)
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_name='jdoe',
|
||||
contact_email='jdoe@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'username': 'jdoe',
|
||||
'given_name': 'Jane',
|
||||
'family_name': 'Doe',
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
|
||||
assert org.contact_name == 'Jane Doe'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_contact_name_preserves_custom_value(session_maker):
|
||||
"""When contact_name differs from both username fields, do not overwrite it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
# Org has a custom contact_name set via PATCH endpoint
|
||||
with session_maker() as session:
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
name=f'user_{user_id}_org',
|
||||
contact_name='Custom Corp Name',
|
||||
contact_email='jdoe@example.com',
|
||||
)
|
||||
session.add(org)
|
||||
session.commit()
|
||||
|
||||
user_info = {
|
||||
'preferred_username': 'jdoe',
|
||||
'username': 'jdoe',
|
||||
'name': 'John Doe',
|
||||
}
|
||||
|
||||
with patch(
|
||||
'storage.user_store.a_session_maker',
|
||||
_wrap_sync_as_async_session_maker(session_maker),
|
||||
):
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
|
||||
assert org.contact_name == 'Custom Corp Name'
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Shared identity helpers for resolving user profile fields from Keycloak claims."""
|
||||
|
||||
|
||||
def resolve_display_name(user_info: dict) -> str | None:
|
||||
"""Resolve the best available display name from Keycloak user_info claims.
|
||||
|
||||
Fallback chain: name → given_name + family_name → None
|
||||
|
||||
Does NOT fall back to preferred_username/username — callers that need
|
||||
a guaranteed non-None value should handle that separately. This keeps
|
||||
the helper focused on real-name claims so that the /api/user/info route
|
||||
can return name=None when no real name is available, while user_store
|
||||
callers can append their own username fallback.
|
||||
"""
|
||||
name = user_info.get('name', '')
|
||||
if name and name.strip():
|
||||
return name.strip()
|
||||
|
||||
given = user_info.get('given_name', '').strip()
|
||||
family = user_info.get('family_name', '').strip()
|
||||
combined = f'{given} {family}'.strip()
|
||||
if combined:
|
||||
return combined
|
||||
|
||||
return None
|
||||
@@ -201,7 +201,7 @@ describe("GitRepoDropdown", () => {
|
||||
const selectedRepository = MOCK_REPOSITORIES[0];
|
||||
|
||||
renderDropdown(
|
||||
{ value: selectedRepository.id },
|
||||
{ value: selectedRepository.full_name },
|
||||
{ selectedRepository },
|
||||
);
|
||||
|
||||
@@ -230,24 +230,5 @@ describe("GitRepoDropdown", () => {
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(MOCK_REPOSITORIES[1]);
|
||||
});
|
||||
|
||||
it("should keep selected repo visible even if it's not in fetched results", async () => {
|
||||
// selectedRepository from useRepositoryData stays null in our default mocks,
|
||||
// which matches the real-world scenario where a recent repo isn't yet loaded.
|
||||
renderDropdown();
|
||||
|
||||
const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
|
||||
await userEvent.click(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("user/repo-two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("user/repo-two"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.value).toBe("user/repo-two");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@ describe("PostHogWrapper", () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset URL hash
|
||||
window.location.hash = "";
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
// Mock the config fetch
|
||||
// @ts-expect-error - partial mock
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
@@ -26,9 +24,9 @@ describe("PostHogWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should initialize PostHog with bootstrap IDs from URL hash (without ph_ prefix)", async () => {
|
||||
// Webflow sends distinct_id and session_id without the ph_ prefix
|
||||
window.location.hash = "distinct_id=user-123&session_id=session-456";
|
||||
it("should initialize PostHog with bootstrap IDs from URL hash", async () => {
|
||||
// Set up URL hash with cross-domain tracking params
|
||||
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
|
||||
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
@@ -36,8 +34,10 @@ describe("PostHogWrapper", () => {
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
// Wait for async config fetch and PostHog initialization
|
||||
await screen.findByTestId("child");
|
||||
|
||||
// Verify PostHogProvider was called with bootstrap options
|
||||
expect(mockPostHogProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
@@ -51,7 +51,8 @@ describe("PostHogWrapper", () => {
|
||||
});
|
||||
|
||||
it("should clean up URL hash after extracting bootstrap IDs", async () => {
|
||||
window.location.hash = "distinct_id=user-123&session_id=session-456";
|
||||
// Set up URL hash with cross-domain tracking params
|
||||
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
|
||||
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
@@ -59,98 +60,10 @@ describe("PostHogWrapper", () => {
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
// Wait for async config fetch and PostHog initialization
|
||||
await screen.findByTestId("child");
|
||||
|
||||
// Verify URL hash was cleaned up
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("should persist bootstrap IDs to sessionStorage for OAuth survival", async () => {
|
||||
window.location.hash = "distinct_id=user-123&session_id=session-456";
|
||||
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
<div data-testid="child" />
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("child");
|
||||
|
||||
// After extracting from hash, IDs should NOT remain in sessionStorage
|
||||
// because they were already consumed during this page load.
|
||||
// But if a full-page redirect happened before PostHog init,
|
||||
// sessionStorage would still have them for the next load.
|
||||
// We verify the write happened by checking the provider received the IDs.
|
||||
expect(mockPostHogProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
bootstrap: {
|
||||
distinctID: "user-123",
|
||||
sessionID: "session-456",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should read bootstrap IDs from sessionStorage when hash is absent (post-OAuth)", async () => {
|
||||
// Simulate returning from OAuth: no hash, but sessionStorage has the IDs
|
||||
sessionStorage.setItem(
|
||||
"posthog_bootstrap",
|
||||
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
|
||||
);
|
||||
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
<div data-testid="child" />
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("child");
|
||||
|
||||
expect(mockPostHogProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
bootstrap: {
|
||||
distinctID: "user-123",
|
||||
sessionID: "session-456",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should clean up sessionStorage after consuming bootstrap IDs", async () => {
|
||||
sessionStorage.setItem(
|
||||
"posthog_bootstrap",
|
||||
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
|
||||
);
|
||||
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
<div data-testid="child" />
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("child");
|
||||
|
||||
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
|
||||
});
|
||||
|
||||
it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => {
|
||||
render(
|
||||
<PostHogWrapper>
|
||||
<div data-testid="child" />
|
||||
</PostHogWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("child");
|
||||
|
||||
expect(mockPostHogProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
bootstrap: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export function ConversationLoading() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="bg-[#25272D] flex flex-col items-center justify-center h-full w-full">
|
||||
<div className="bg-[#25272D] border border-[#525252] rounded-xl flex flex-col items-center justify-center h-full w-full">
|
||||
<LoaderCircle className="animate-spin w-16 h-16" color="white" />
|
||||
<span className="text-2xl font-normal leading-5 text-white p-4">
|
||||
{t(I18nKey.HOME$LOADING)}
|
||||
|
||||
@@ -91,13 +91,6 @@ export function GitRepoDropdown({
|
||||
repositoryName,
|
||||
);
|
||||
|
||||
/**
|
||||
* `selectedRepository` is derived from fetched/search results. A repo selected
|
||||
* from "Most Recent" may not be present in those results yet, so use the
|
||||
* locally selected item as a fallback to keep the UI stable.
|
||||
*/
|
||||
const effectiveSelectedRepository = selectedRepository ?? localSelectedItem;
|
||||
|
||||
// Get recent repositories filtered by provider and input keyword
|
||||
const recentRepositories = useMemo(() => {
|
||||
const allRecentRepos = storedRecentRepositories;
|
||||
@@ -240,9 +233,12 @@ export function GitRepoDropdown({
|
||||
|
||||
// Initialize input value when selectedRepository changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
setInputValue(effectiveSelectedRepository?.full_name ?? "");
|
||||
}, [effectiveSelectedRepository, isOpen]);
|
||||
if (selectedRepository && !isOpen) {
|
||||
setInputValue(selectedRepository.full_name);
|
||||
} else if (!selectedRepository && !isOpen) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedRepository, isOpen]);
|
||||
|
||||
const isLoadingState =
|
||||
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
|
||||
@@ -348,7 +344,7 @@ export function GitRepoDropdown({
|
||||
/>
|
||||
|
||||
<div className="absolute right-1 top-1/2 transform -translate-y-1/2 flex items-center">
|
||||
{effectiveSelectedRepository && (
|
||||
{selectedRepository && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,37 +3,22 @@ import { PostHogProvider } from "posthog-js/react";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
const POSTHOG_BOOTSTRAP_KEY = "posthog_bootstrap";
|
||||
|
||||
function getBootstrapIds() {
|
||||
// Try to extract from URL hash (e.g. #distinct_id=abc&session_id=xyz)
|
||||
function getBootstrapFromHash() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
const distinctId = params.get("distinct_id");
|
||||
const sessionId = params.get("session_id");
|
||||
const distinctId = params.get("ph_distinct_id");
|
||||
const sessionId = params.get("ph_session_id");
|
||||
|
||||
if (distinctId && sessionId) {
|
||||
const bootstrap = { distinctID: distinctId, sessionID: sessionId };
|
||||
|
||||
// Persist to sessionStorage so IDs survive full-page OAuth redirects
|
||||
sessionStorage.setItem(POSTHOG_BOOTSTRAP_KEY, JSON.stringify(bootstrap));
|
||||
|
||||
// Clean the hash from the URL
|
||||
// Remove the PostHog tracking params from URL hash to keep URL clean
|
||||
// replaceState(state, unused, url) - we pass null state, empty title (ignored by browsers), and the clean URL
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
return bootstrap;
|
||||
return { distinctID: distinctId, sessionID: sessionId };
|
||||
}
|
||||
|
||||
// Fallback: check sessionStorage (covers return from OAuth redirect)
|
||||
const stored = sessionStorage.getItem(POSTHOG_BOOTSTRAP_KEY);
|
||||
if (stored) {
|
||||
sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY);
|
||||
return JSON.parse(stored) as { distinctID: string; sessionID: string };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -42,7 +27,7 @@ export function PostHogWrapper({ children }: { children: React.ReactNode }) {
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const bootstrapIds = React.useMemo(() => getBootstrapIds(), []);
|
||||
const bootstrapIds = React.useMemo(() => getBootstrapFromHash(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
|
||||
@@ -1018,6 +1018,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
new_content[-1] = TextContent(
|
||||
text=last_content.text + plugin_params_message,
|
||||
cache_prompt=last_content.cache_prompt,
|
||||
enable_truncation=last_content.enable_truncation,
|
||||
)
|
||||
else:
|
||||
# Add as new text content
|
||||
|
||||
@@ -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:b7d5cdb-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:c775ff6-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
@@ -21,7 +21,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
recaptcha_site_key: str | None = None
|
||||
faulty_models: list[str] = Field(default_factory=list)
|
||||
error_message: str | None = None
|
||||
github_app_slug: str | None = None
|
||||
|
||||
async def get_web_client_config(self) -> WebClientConfig:
|
||||
from openhands.app_server.config import get_global_config
|
||||
@@ -37,6 +36,5 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
recaptcha_site_key=self.recaptcha_site_key,
|
||||
faulty_models=self.faulty_models,
|
||||
error_message=self.error_message,
|
||||
github_app_slug=self.github_app_slug,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -25,4 +25,3 @@ class WebClientConfig(DiscriminatedUnionMixin):
|
||||
recaptcha_site_key: str | None
|
||||
faulty_models: list[str]
|
||||
error_message: str | None
|
||||
github_app_slug: str | None
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
@@ -25,18 +24,6 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
|
||||
|
||||
BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
|
||||
@staticmethod
|
||||
def _resolve_primary_email(emails: list[dict]) -> str | None:
|
||||
"""Find the primary confirmed email from a list of Bitbucket email objects.
|
||||
|
||||
Bitbucket's /user/emails endpoint returns objects with
|
||||
'email', 'is_primary', and 'is_confirmed' keys.
|
||||
"""
|
||||
for entry in emails:
|
||||
if entry.get('is_primary') and entry.get('is_confirmed'):
|
||||
return entry.get('email')
|
||||
return None
|
||||
|
||||
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
|
||||
"""Extract owner and repo from repository string.
|
||||
|
||||
@@ -150,17 +137,6 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
|
||||
|
||||
return all_items[:max_items]
|
||||
|
||||
async def get_user_emails(self) -> list[dict]:
|
||||
"""Fetch the authenticated user's email addresses from Bitbucket.
|
||||
|
||||
Calls GET /user/emails which returns a paginated response with a
|
||||
'values' list of email objects containing 'email', 'is_primary',
|
||||
and 'is_confirmed' fields.
|
||||
"""
|
||||
url = f'{self.BASE_URL}/user/emails'
|
||||
response, _ = await self._make_request(url)
|
||||
return response.get('values', [])
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user's information."""
|
||||
url = f'{self.BASE_URL}/user'
|
||||
@@ -168,22 +144,12 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
|
||||
|
||||
account_id = data.get('account_id', '')
|
||||
|
||||
email = None
|
||||
try:
|
||||
emails = await self.get_user_emails()
|
||||
email = self._resolve_primary_email(emails)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'bitbucket:get_user:email_fallback_failed',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return User(
|
||||
id=account_id,
|
||||
login=data.get('username', ''),
|
||||
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
|
||||
name=data.get('display_name'),
|
||||
email=email,
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any, cast
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.protocols.http_client import HTTPClient
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
@@ -23,19 +22,6 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
|
||||
BASE_URL: str
|
||||
GRAPHQL_URL: str
|
||||
|
||||
@staticmethod
|
||||
def _resolve_primary_email(emails: list[dict]) -> str | None:
|
||||
"""Find the primary verified email from a list of GitHub email objects.
|
||||
|
||||
GitHub's /user/emails endpoint returns a list of dicts, each with
|
||||
'email', 'primary', and 'verified' keys. This selects the one marked
|
||||
as both primary and verified — the email the user considers canonical.
|
||||
"""
|
||||
for entry in emails:
|
||||
if entry.get('primary') and entry.get('verified'):
|
||||
return entry.get('email')
|
||||
return None
|
||||
|
||||
async def _get_headers(self) -> dict:
|
||||
"""Retrieve the GH Token from settings store to construct the headers."""
|
||||
if not self.token:
|
||||
@@ -121,17 +107,6 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def get_user_emails(self) -> list[dict]:
|
||||
"""Fetch the authenticated user's email addresses from GitHub.
|
||||
|
||||
Calls GET /user/emails which returns a list of email objects, each
|
||||
containing 'email', 'primary', 'verified', and 'visibility' fields.
|
||||
Requires the user:email OAuth scope.
|
||||
"""
|
||||
url = f'{self.BASE_URL}/user/emails'
|
||||
response, _ = await self._make_request(url)
|
||||
return response
|
||||
|
||||
async def verify_access(self) -> bool:
|
||||
url = f'{self.BASE_URL}'
|
||||
await self._make_request(url)
|
||||
@@ -141,22 +116,11 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
|
||||
url = f'{self.BASE_URL}/user'
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
email = response.get('email')
|
||||
if email is None:
|
||||
try:
|
||||
emails = await self.get_user_emails()
|
||||
email = self._resolve_primary_email(emails)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'github:get_user:email_fallback_failed',
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=cast(str, response.get('login') or ''),
|
||||
avatar_url=cast(str, response.get('avatar_url') or ''),
|
||||
company=response.get('company'),
|
||||
name=response.get('name'),
|
||||
email=email,
|
||||
email=response.get('email'),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import base62
|
||||
import httpx
|
||||
@@ -41,7 +40,6 @@ from openhands.app_server.config import (
|
||||
depends_httpx_client,
|
||||
depends_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
@@ -121,7 +119,6 @@ app_conversation_info_service_dependency = depends_app_conversation_info_service
|
||||
sandbox_service_dependency = depends_sandbox_service()
|
||||
db_session_dependency = depends_db_session()
|
||||
httpx_client_dependency = depends_httpx_client()
|
||||
_RESUME_GRACE_PERIOD = 60
|
||||
|
||||
|
||||
def _filter_conversations_by_age(
|
||||
@@ -473,7 +470,6 @@ async def get_conversation(
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
app_conversation_service: AppConversationService = app_conversation_service_dependency,
|
||||
httpx_client: httpx.AsyncClient = httpx_client_dependency,
|
||||
) -> ConversationInfo | None:
|
||||
"""Get a single conversation by ID.
|
||||
|
||||
@@ -488,44 +484,6 @@ async def get_conversation(
|
||||
conversation_uuid
|
||||
)
|
||||
if app_conversation:
|
||||
if (
|
||||
app_conversation.sandbox_status == SandboxStatus.RUNNING
|
||||
and app_conversation.execution_status is None
|
||||
):
|
||||
# The sandbox is running, but we were unable to determine a status for
|
||||
# the conversation. It may be that it is still starting, or that the
|
||||
# conversation has been stopped / deleted.
|
||||
try:
|
||||
# Check the server info is available
|
||||
conversation_url = urlparse(app_conversation.conversation_url)
|
||||
sandbox_info_url = f'{str(conversation_url.scheme)}://{str(conversation_url.netloc)}/server_info'
|
||||
response = await httpx_client.get(sandbox_info_url)
|
||||
response.raise_for_status()
|
||||
server_info = response.json()
|
||||
|
||||
# If the server has not been running long, we consider it still starting
|
||||
uptime = int(server_info.get('uptime'))
|
||||
if uptime < _RESUME_GRACE_PERIOD:
|
||||
app_conversation.sandbox_status = SandboxStatus.STARTING
|
||||
|
||||
except Exception:
|
||||
# The sandbox is marked as RUNNING, but the server is not responding.
|
||||
# There is a bug in runtime API which means that the server is marked
|
||||
# as RUNNING before it is actually started. (Primarily affecting resumed
|
||||
# runtimes) As a temporary work around for this, we mark the server as
|
||||
# STARTING. If the sandbox is actually in an error state, the API will
|
||||
# discover this quite quickly and mark the sandbox as ERROR
|
||||
logger.warning(
|
||||
'get_sandbox_info_failed',
|
||||
extra={
|
||||
'conversation_id': app_conversation.id,
|
||||
'sandbox_id': app_conversation.sandbox_id,
|
||||
},
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
app_conversation.sandbox_status = SandboxStatus.STARTING
|
||||
|
||||
return _to_conversation_info(app_conversation)
|
||||
except (ValueError, TypeError, Exception):
|
||||
# Not a V1 conversation or service error
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -32,10 +30,6 @@ from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
LITE_LLM_API_URL = os.environ.get(
|
||||
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
|
||||
)
|
||||
|
||||
app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@@ -120,8 +114,10 @@ async def reset_settings() -> JSONResponse:
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
settings: Settings, existing_settings: Settings
|
||||
settings: Settings, settings_store: SettingsStore
|
||||
) -> Settings:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
# Keep existing LLM settings if not provided
|
||||
@@ -129,9 +125,8 @@ async def store_llm_settings(
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
if settings.llm_model is None:
|
||||
settings.llm_model = existing_settings.llm_model
|
||||
# if llm_base_url is missing or empty, set to default as this only happens for "basic" settings
|
||||
if not settings.llm_base_url:
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
if settings.llm_base_url is None:
|
||||
settings.llm_base_url = existing_settings.llm_base_url
|
||||
# Keep search API key if missing or empty
|
||||
if not settings.search_api_key:
|
||||
settings.search_api_key = existing_settings.search_api_key
|
||||
@@ -161,7 +156,7 @@ async def store_settings(
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, existing_settings)
|
||||
settings = await store_llm_settings(settings, settings_store)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
|
||||
20
poetry.lock
generated
20
poetry.lock
generated
@@ -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.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
|
||||
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
|
||||
{file = "openhands_agent_server-1.10.0-py3-none-any.whl", hash = "sha256:2e21076fff5e7cf9d03a3b011e2c90a6a3a46d2da3f18db9f7553ac413229c22"},
|
||||
{file = "openhands_agent_server-1.10.0.tar.gz", hash = "sha256:2062da2496a98a6c23201d086f124e02329d6c6d9d1b47be55921c084a29f55a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6259,14 +6259,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.11.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
|
||||
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
|
||||
{file = "openhands_sdk-1.10.0-py3-none-any.whl", hash = "sha256:5c8875f2a07d7fabe3449914639572bef9003821207cb06aa237a239e964eed5"},
|
||||
{file = "openhands_sdk-1.10.0.tar.gz", hash = "sha256:93371b1af4532266ad2d225b9d7d3d711c745df31888efe643970673f62bdef9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6287,14 +6287,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.11.1"
|
||||
version = "1.10.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
|
||||
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
|
||||
{file = "openhands_tools-1.10.0-py3-none-any.whl", hash = "sha256:1d5d2d1e34cc4ceb02c0ff1f008b06883ad48a8e7236ab8dd61ece64fbf8e2ed"},
|
||||
{file = "openhands_tools-1.10.0.tar.gz", hash = "sha256:7ed38cb13545ec2c4a35c26ece725d5b35788d30597db8b1904619c043ec1194"},
|
||||
]
|
||||
|
||||
[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 = "b9dc8da02728200ec103f498048e95abf2d91ba2c518fb24ba03e83064a1e450"
|
||||
content-hash = "e95cfdd9837e0a58eaa3864dffcd46f5049ca3dfe48b67acb848f476dd8adce1"
|
||||
|
||||
@@ -54,9 +54,9 @@ dependencies = [
|
||||
"numpy",
|
||||
"openai==2.8",
|
||||
"openhands-aci==0.3.2",
|
||||
"openhands-agent-server==1.11.1",
|
||||
"openhands-sdk==1.11.1",
|
||||
"openhands-tools==1.11.1",
|
||||
"openhands-agent-server==1.10",
|
||||
"openhands-sdk==1.10",
|
||||
"openhands-tools==1.10",
|
||||
"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.1"
|
||||
openhands-agent-server = "1.11.1"
|
||||
openhands-tools = "1.11.1"
|
||||
openhands-sdk = "1.10"
|
||||
openhands-agent-server = "1.10"
|
||||
openhands-tools = "1.10"
|
||||
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
@@ -709,138 +709,6 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
assert org_repo.owner_type == OwnerType.ORGANIZATION
|
||||
|
||||
|
||||
# ── Bitbucket email fallback tests ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_selects_primary_confirmed():
|
||||
"""_resolve_primary_email returns the email marked primary and confirmed."""
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'secondary@example.com', 'is_primary': False, 'is_confirmed': True},
|
||||
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
|
||||
{
|
||||
'email': 'unconfirmed@example.com',
|
||||
'is_primary': False,
|
||||
'is_confirmed': False,
|
||||
},
|
||||
]
|
||||
result = BitBucketMixinBase._resolve_primary_email(emails)
|
||||
assert result == 'primary@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_when_no_primary():
|
||||
"""_resolve_primary_email returns None when no email is marked primary."""
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'a@example.com', 'is_primary': False, 'is_confirmed': True},
|
||||
{'email': 'b@example.com', 'is_primary': False, 'is_confirmed': True},
|
||||
]
|
||||
result = BitBucketMixinBase._resolve_primary_email(emails)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_when_primary_not_confirmed():
|
||||
"""_resolve_primary_email returns None when primary email is not confirmed."""
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': False},
|
||||
{'email': 'other@example.com', 'is_primary': False, 'is_confirmed': True},
|
||||
]
|
||||
result = BitBucketMixinBase._resolve_primary_email(emails)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_for_empty_list():
|
||||
"""_resolve_primary_email returns None for an empty list."""
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
|
||||
result = BitBucketMixinBase._resolve_primary_email([])
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_emails():
|
||||
"""get_user_emails calls /user/emails and returns the values list."""
|
||||
service = BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
mock_response = {
|
||||
'values': [
|
||||
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
|
||||
{
|
||||
'email': 'secondary@example.com',
|
||||
'is_primary': False,
|
||||
'is_confirmed': True,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_response, {})):
|
||||
emails = await service.get_user_emails()
|
||||
|
||||
assert emails == mock_response['values']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_falls_back_to_user_emails():
|
||||
"""get_user calls /user/emails to resolve email (Bitbucket /user never returns email)."""
|
||||
service = BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
mock_user_response = {
|
||||
'account_id': '123',
|
||||
'username': 'testuser',
|
||||
'display_name': 'Test User',
|
||||
'links': {'avatar': {'href': 'https://example.com/avatar.jpg'}},
|
||||
}
|
||||
|
||||
mock_emails = [
|
||||
{'email': 'secondary@example.com', 'is_primary': False, 'is_confirmed': True},
|
||||
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
|
||||
patch.object(service, 'get_user_emails', return_value=mock_emails),
|
||||
):
|
||||
user = await service.get_user()
|
||||
|
||||
assert user.email == 'primary@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_handles_user_emails_api_failure():
|
||||
"""get_user handles /user/emails failure gracefully — email stays None."""
|
||||
service = BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
mock_user_response = {
|
||||
'account_id': '123',
|
||||
'username': 'testuser',
|
||||
'display_name': 'Test User',
|
||||
'links': {'avatar': {'href': 'https://example.com/avatar.jpg'}},
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
|
||||
patch.object(
|
||||
service,
|
||||
'get_user_emails',
|
||||
side_effect=Exception('API Error'),
|
||||
),
|
||||
):
|
||||
user = await service.get_user()
|
||||
|
||||
# Email should remain None — no crash
|
||||
assert user.email is None
|
||||
assert user.login == 'testuser'
|
||||
assert user.name == 'Test User'
|
||||
|
||||
|
||||
# Setup.py Bitbucket Token Tests
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
|
||||
@@ -405,153 +405,6 @@ async def test_github_service_graphql_url_enterprise_server():
|
||||
assert actual_url == 'https://github.enterprise.com/api/graphql'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_selects_primary_verified():
|
||||
"""_resolve_primary_email returns the email marked primary and verified."""
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
|
||||
{'email': 'primary@example.com', 'primary': True, 'verified': True},
|
||||
{'email': 'unverified@example.com', 'primary': False, 'verified': False},
|
||||
]
|
||||
result = GitHubMixinBase._resolve_primary_email(emails)
|
||||
assert result == 'primary@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_when_no_primary():
|
||||
"""_resolve_primary_email returns None when no email is marked primary."""
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'a@example.com', 'primary': False, 'verified': True},
|
||||
{'email': 'b@example.com', 'primary': False, 'verified': True},
|
||||
]
|
||||
result = GitHubMixinBase._resolve_primary_email(emails)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_when_primary_not_verified():
|
||||
"""_resolve_primary_email returns None when primary email is not verified."""
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
|
||||
emails = [
|
||||
{'email': 'primary@example.com', 'primary': True, 'verified': False},
|
||||
{'email': 'other@example.com', 'primary': False, 'verified': True},
|
||||
]
|
||||
result = GitHubMixinBase._resolve_primary_email(emails)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_primary_email_returns_none_for_empty_list():
|
||||
"""_resolve_primary_email returns None for an empty list."""
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
|
||||
result = GitHubMixinBase._resolve_primary_email([])
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_emails():
|
||||
"""get_user_emails calls /user/emails and returns the response."""
|
||||
service = GitHubService(user_id=None, token=SecretStr('test-token'))
|
||||
|
||||
mock_emails = [
|
||||
{'email': 'primary@example.com', 'primary': True, 'verified': True},
|
||||
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request', return_value=(mock_emails, {})):
|
||||
emails = await service.get_user_emails()
|
||||
|
||||
assert emails == mock_emails
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_uses_email_from_user_endpoint():
|
||||
"""get_user does NOT call /user/emails when /user returns an email."""
|
||||
service = GitHubService(user_id=None, token=SecretStr('test-token'))
|
||||
|
||||
mock_user_response = {
|
||||
'id': 123,
|
||||
'login': 'testuser',
|
||||
'avatar_url': 'https://example.com/avatar.jpg',
|
||||
'company': 'TestCo',
|
||||
'name': 'Test User',
|
||||
'email': 'user@example.com',
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
service, '_make_request', return_value=(mock_user_response, {})
|
||||
) as mock_request:
|
||||
user = await service.get_user()
|
||||
|
||||
assert user.email == 'user@example.com'
|
||||
# Only one call to _make_request (/user), no /user/emails call
|
||||
mock_request.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_falls_back_to_user_emails():
|
||||
"""get_user calls /user/emails when /user returns null email."""
|
||||
service = GitHubService(user_id=None, token=SecretStr('test-token'))
|
||||
|
||||
mock_user_response = {
|
||||
'id': 123,
|
||||
'login': 'testuser',
|
||||
'avatar_url': 'https://example.com/avatar.jpg',
|
||||
'company': 'TestCo',
|
||||
'name': 'Test User',
|
||||
'email': None,
|
||||
}
|
||||
|
||||
mock_emails = [
|
||||
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
|
||||
{'email': 'primary@example.com', 'primary': True, 'verified': True},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
|
||||
patch.object(service, 'get_user_emails', return_value=mock_emails),
|
||||
):
|
||||
user = await service.get_user()
|
||||
|
||||
assert user.email == 'primary@example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_handles_user_emails_api_failure():
|
||||
"""get_user handles /user/emails failure gracefully — email stays None."""
|
||||
service = GitHubService(user_id=None, token=SecretStr('test-token'))
|
||||
|
||||
mock_user_response = {
|
||||
'id': 123,
|
||||
'login': 'testuser',
|
||||
'avatar_url': 'https://example.com/avatar.jpg',
|
||||
'company': None,
|
||||
'name': 'Test User',
|
||||
'email': None,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
|
||||
patch.object(
|
||||
service,
|
||||
'get_user_emails',
|
||||
side_effect=Exception('API Error'),
|
||||
),
|
||||
):
|
||||
user = await service.get_user()
|
||||
|
||||
# Email should remain None — no crash
|
||||
assert user.email is None
|
||||
assert user.login == 'testuser'
|
||||
assert user.name == 'Test User'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_service_graphql_url_github_com():
|
||||
"""Test that GraphQL URL is correctly constructed for GitHub.com."""
|
||||
|
||||
388
tests/unit/runtime/utils/test_bash_session.py
Normal file
388
tests/unit/runtime/utils/test_bash_session.py
Normal file
@@ -0,0 +1,388 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
|
||||
|
||||
def get_no_change_timeout_suffix(timeout_seconds):
|
||||
"""Helper function to generate the expected no-change timeout suffix."""
|
||||
return (
|
||||
f'\n[The command has no new output after {timeout_seconds} seconds. '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
|
||||
def test_session_initialization():
|
||||
# Test with custom working directory
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
session = BashSession(work_dir=temp_dir)
|
||||
session.initialize()
|
||||
obs = session.execute(CmdRunAction('pwd'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert temp_dir in obs.content
|
||||
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
|
||||
session.close()
|
||||
|
||||
# Test with custom username
|
||||
session = BashSession(work_dir=os.getcwd(), username='nobody')
|
||||
session.initialize()
|
||||
assert 'openhands-nobody' in session.session.name
|
||||
session.close()
|
||||
|
||||
|
||||
def test_cwd_property(tmp_path):
|
||||
session = BashSession(work_dir=tmp_path)
|
||||
session.initialize()
|
||||
# Change directory and verify pwd updates
|
||||
random_dir = tmp_path / 'random'
|
||||
random_dir.mkdir()
|
||||
session.execute(CmdRunAction(f'cd {random_dir}'))
|
||||
assert session.cwd == str(random_dir)
|
||||
session.close()
|
||||
|
||||
|
||||
def test_basic_command():
|
||||
session = BashSession(work_dir=os.getcwd())
|
||||
session.initialize()
|
||||
|
||||
# Test simple command
|
||||
obs = session.execute(CmdRunAction("echo 'hello world'"))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'hello world' in obs.content
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
# Test command with error
|
||||
obs = session.execute(CmdRunAction('nonexistent_command'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.metadata.exit_code == 127
|
||||
assert 'nonexistent_command: command not found' in obs.content
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 127.]'
|
||||
assert obs.metadata.prefix == ''
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
# Test multiple commands in sequence
|
||||
obs = session.execute(CmdRunAction('echo "first" && echo "second" && echo "third"'))
|
||||
assert 'first' in obs.content
|
||||
assert 'second' in obs.content
|
||||
assert 'third' in obs.content
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_long_running_command_follow_by_execute():
|
||||
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
|
||||
session.initialize()
|
||||
|
||||
# Test command that produces output slowly
|
||||
obs = session.execute(
|
||||
CmdRunAction('for i in {1..3}; do echo $i; sleep 3; done', blocking=False)
|
||||
)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert '1' in obs.content # First number should appear before timeout
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
# Continue watching output
|
||||
obs = session.execute(CmdRunAction('', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert '2' in obs.content
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
# Test command that produces no output
|
||||
obs = session.execute(CmdRunAction('sleep 15'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert '3' not in obs.content
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
assert 'The previous command is still running' in obs.metadata.suffix
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
# Run it again, this time it should produce output
|
||||
obs = session.execute(CmdRunAction('sleep 15'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert '3' in obs.content
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
assert 'The previous command is still running' in obs.metadata.suffix
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_interactive_command():
|
||||
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=3)
|
||||
session.initialize()
|
||||
|
||||
# Test interactive command with blocking=True
|
||||
obs = session.execute(
|
||||
CmdRunAction(
|
||||
'read -p \'Enter name: \' name && echo "Hello $name"',
|
||||
)
|
||||
)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Enter name:' in obs.content
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
# Send input
|
||||
obs = session.execute(CmdRunAction('John', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Hello John' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
assert obs.metadata.prefix == ''
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
# Test multiline command input
|
||||
obs = session.execute(CmdRunAction('cat << EOF'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.metadata.exit_code == -1
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
obs = session.execute(CmdRunAction('line 1', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.metadata.exit_code == -1
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
|
||||
obs = session.execute(CmdRunAction('line 2', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.metadata.exit_code == -1
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
|
||||
obs = session.execute(CmdRunAction('EOF', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'line 1' in obs.content and 'line 2' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_ctrl_c():
|
||||
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
|
||||
session.initialize()
|
||||
|
||||
# Start infinite loop
|
||||
obs = session.execute(
|
||||
CmdRunAction("while true; do echo 'looping'; sleep 3; done"),
|
||||
)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'looping' in obs.content
|
||||
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
# Send Ctrl+C
|
||||
obs = session.execute(CmdRunAction('C-c', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
# Check that the process was interrupted (exit code can be 1 or 130 depending on the shell/OS)
|
||||
assert obs.metadata.exit_code in (
|
||||
1,
|
||||
130,
|
||||
) # Accept both common exit codes for interrupted processes
|
||||
assert 'CTRL+C was sent' in obs.metadata.suffix
|
||||
assert obs.metadata.prefix == ''
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_empty_command_errors():
|
||||
session = BashSession(work_dir=os.getcwd())
|
||||
session.initialize()
|
||||
|
||||
# Test empty command without previous command
|
||||
obs = session.execute(CmdRunAction(''))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert obs.content == 'ERROR: No previous running command to retrieve logs from.'
|
||||
assert obs.metadata.exit_code == -1
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.suffix == ''
|
||||
assert session.prev_status is None
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_command_output_continuation():
|
||||
"""Test that we can continue to get output from a long-running command.
|
||||
|
||||
This test has been modified to be more robust against timing issues.
|
||||
"""
|
||||
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=1)
|
||||
session.initialize()
|
||||
|
||||
# Start a command that produces output slowly but with longer sleep time
|
||||
# to ensure we hit the timeout
|
||||
obs = session.execute(CmdRunAction('for i in {1..5}; do echo $i; sleep 2; done'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
# Check if the command completed immediately or timed out
|
||||
if session.prev_status == BashCommandStatus.COMPLETED:
|
||||
# If the command completed immediately, verify we got all the output
|
||||
logger.info('Command completed immediately', extra={'msg_type': 'TEST_INFO'})
|
||||
assert '1' in obs.content
|
||||
assert '2' in obs.content
|
||||
assert '3' in obs.content
|
||||
assert '4' in obs.content
|
||||
assert '5' in obs.content
|
||||
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
|
||||
else:
|
||||
# If the command timed out, verify we got the timeout message
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
assert '1' in obs.content
|
||||
assert '[The command has no new output after 1 seconds.' in obs.metadata.suffix
|
||||
|
||||
# Continue getting output until we see all numbers
|
||||
numbers_seen = set()
|
||||
for i in range(1, 6):
|
||||
if str(i) in obs.content:
|
||||
numbers_seen.add(i)
|
||||
|
||||
# We need to see numbers 2-5 and then the command completion
|
||||
while (
|
||||
len(numbers_seen) < 5 or session.prev_status != BashCommandStatus.COMPLETED
|
||||
):
|
||||
obs = session.execute(CmdRunAction('', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
# Check for numbers in the output
|
||||
for i in range(1, 6):
|
||||
if str(i) in obs.content and i not in numbers_seen:
|
||||
numbers_seen.add(i)
|
||||
logger.info(
|
||||
f'Found number {i} in output', extra={'msg_type': 'TEST_INFO'}
|
||||
)
|
||||
|
||||
# Check if the command has completed
|
||||
if session.prev_status == BashCommandStatus.COMPLETED:
|
||||
assert (
|
||||
'[The command completed with exit code 0.]' in obs.metadata.suffix
|
||||
)
|
||||
break
|
||||
else:
|
||||
assert (
|
||||
'[The command has no new output after 1 seconds.'
|
||||
in obs.metadata.suffix
|
||||
)
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
# Verify we've seen all numbers
|
||||
assert numbers_seen == {1, 2, 3, 4, 5}, (
|
||||
f'Expected to see numbers 1-5, but saw {numbers_seen}'
|
||||
)
|
||||
|
||||
# Verify the command completed
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_long_output():
|
||||
session = BashSession(work_dir=os.getcwd())
|
||||
session.initialize()
|
||||
|
||||
# Generate a long output that may exceed buffer size
|
||||
obs = session.execute(CmdRunAction('for i in {1..5000}; do echo "Line $i"; done'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Line 1' in obs.content
|
||||
assert 'Line 5000' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_long_output_exceed_history_limit():
|
||||
session = BashSession(work_dir=os.getcwd())
|
||||
session.initialize()
|
||||
|
||||
# Generate a long output that may exceed buffer size
|
||||
obs = session.execute(CmdRunAction('for i in {1..50000}; do echo "Line $i"; done'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Previous command outputs are truncated' in obs.metadata.prefix
|
||||
assert 'Line 40000' in obs.content
|
||||
assert 'Line 50000' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_multiline_command():
|
||||
session = BashSession(work_dir=os.getcwd())
|
||||
session.initialize()
|
||||
|
||||
# Test multiline command with PS2 prompt disabled
|
||||
obs = session.execute(
|
||||
CmdRunAction("""if true; then
|
||||
echo "inside if"
|
||||
fi""")
|
||||
)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'inside if' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.prefix == ''
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def test_python_interactive_input():
|
||||
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
|
||||
session.initialize()
|
||||
|
||||
# Test Python program that asks for input - properly escaped for bash
|
||||
python_script = """name = input('Enter your name: '); age = input('Enter your age: '); print(f'Hello {name}, you are {age} years old')"""
|
||||
|
||||
# Start Python with the interactive script
|
||||
obs = session.execute(CmdRunAction(f'python3 -c "{python_script}"'))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Enter your name:' in obs.content
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
# Send first input (name)
|
||||
obs = session.execute(CmdRunAction('Alice', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Enter your age:' in obs.content
|
||||
assert obs.metadata.exit_code == -1
|
||||
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
|
||||
# Send second input (age)
|
||||
obs = session.execute(CmdRunAction('25', is_input=True))
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert 'Hello Alice, you are 25 years old' in obs.content
|
||||
assert obs.metadata.exit_code == 0
|
||||
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
|
||||
assert session.prev_status == BashCommandStatus.COMPLETED
|
||||
|
||||
session.close()
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -13,7 +12,6 @@ from openhands.app_server.app_conversation.app_conversation_info_service import
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AgentType,
|
||||
AppConversation,
|
||||
AppConversationInfo,
|
||||
AppConversationPage,
|
||||
AppConversationStartRequest,
|
||||
@@ -23,10 +21,8 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
AppConversationService,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent
|
||||
from openhands.microagent.types import MicroagentMetadata, MicroagentType
|
||||
from openhands.server.data_models.conversation_info import ConversationStatus
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
@@ -36,9 +32,7 @@ from openhands.server.routes.conversation import (
|
||||
get_microagents,
|
||||
)
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
_RESUME_GRACE_PERIOD,
|
||||
UpdateConversationRequest,
|
||||
get_conversation,
|
||||
search_conversations,
|
||||
update_conversation,
|
||||
)
|
||||
@@ -1465,102 +1459,3 @@ async def test_search_conversations_include_sub_conversations_with_other_filters
|
||||
assert call_kwargs.get('include_sub_conversations') is True
|
||||
assert call_kwargs.get('page_id') == 'test_v1_page_id'
|
||||
assert call_kwargs.get('limit') == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'sandbox_status,execution_status,server_uptime,server_error,expected_status,should_call_server',
|
||||
[
|
||||
# RUNNING sandbox, no execution_status, server responds within grace period -> STARTING
|
||||
(SandboxStatus.RUNNING, None, 30, None, ConversationStatus.STARTING, True),
|
||||
# RUNNING sandbox, no execution_status, server responds past grace period -> RUNNING
|
||||
(
|
||||
SandboxStatus.RUNNING,
|
||||
None,
|
||||
_RESUME_GRACE_PERIOD + 10,
|
||||
None,
|
||||
ConversationStatus.RUNNING,
|
||||
True,
|
||||
),
|
||||
# RUNNING sandbox, no execution_status, server unresponsive -> STARTING
|
||||
(
|
||||
SandboxStatus.RUNNING,
|
||||
None,
|
||||
None,
|
||||
httpx.ConnectError('Connection refused'),
|
||||
ConversationStatus.STARTING,
|
||||
True,
|
||||
),
|
||||
# RUNNING sandbox, WITH execution_status -> RUNNING, skip server check
|
||||
(SandboxStatus.RUNNING, 'IDLE', None, None, ConversationStatus.RUNNING, False),
|
||||
# Non-RUNNING sandbox -> STARTING, skip server check
|
||||
(SandboxStatus.STARTING, None, None, None, ConversationStatus.STARTING, False),
|
||||
],
|
||||
ids=[
|
||||
'running_no_exec_status_within_grace_period',
|
||||
'running_no_exec_status_past_grace_period',
|
||||
'running_no_exec_status_server_unresponsive',
|
||||
'running_with_exec_status_skips_check',
|
||||
'non_running_skips_check',
|
||||
],
|
||||
)
|
||||
async def test_get_conversation_resume_status_handling(
|
||||
sandbox_status,
|
||||
execution_status,
|
||||
server_uptime,
|
||||
server_error,
|
||||
expected_status,
|
||||
should_call_server,
|
||||
):
|
||||
"""Test get_conversation handles resume status correctly for various scenarios."""
|
||||
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
||||
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Convert string execution_status to enum if provided
|
||||
exec_status = None
|
||||
if execution_status == 'IDLE':
|
||||
exec_status = ConversationExecutionStatus.IDLE
|
||||
|
||||
mock_app_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test_sandbox',
|
||||
sandbox_status=sandbox_status,
|
||||
execution_status=exec_status,
|
||||
conversation_url='https://sandbox.example.com/conversation',
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
mock_app_conversation_service = AsyncMock(spec=AppConversationService)
|
||||
mock_app_conversation_service.get_app_conversation.return_value = (
|
||||
mock_app_conversation
|
||||
)
|
||||
mock_conversation_store = AsyncMock(spec=ConversationStore)
|
||||
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
if server_error:
|
||||
mock_httpx_client.get.side_effect = server_error
|
||||
elif server_uptime is not None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {'uptime': server_uptime}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_httpx_client.get.return_value = mock_response
|
||||
|
||||
result = await get_conversation(
|
||||
conversation_id=str(conversation_id),
|
||||
conversation_store=mock_conversation_store,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
httpx_client=mock_httpx_client,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == expected_status
|
||||
|
||||
if should_call_server:
|
||||
mock_httpx_client.get.assert_called_once_with(
|
||||
'https://sandbox.example.com/server_info'
|
||||
)
|
||||
else:
|
||||
mock_httpx_client.get.assert_not_called()
|
||||
|
||||
@@ -149,10 +149,11 @@ async def test_store_llm_settings_new_settings():
|
||||
llm_base_url='https://api.example.com',
|
||||
)
|
||||
|
||||
# No existing settings
|
||||
existing_settings = None
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
|
||||
result = await store_llm_settings(settings, existing_settings)
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with the provided values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
@@ -169,6 +170,9 @@ async def test_store_llm_settings_update_existing():
|
||||
llm_base_url='https://new.example.com',
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
@@ -176,7 +180,9 @@ async def test_store_llm_settings_update_existing():
|
||||
llm_base_url='https://old.example.com',
|
||||
)
|
||||
|
||||
result = await store_llm_settings(settings, existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with the updated values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
@@ -186,16 +192,14 @@ async def test_store_llm_settings_update_existing():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_partial_update():
|
||||
"""Test store_llm_settings with partial update.
|
||||
|
||||
Note: When llm_base_url is not provided in the update, it gets set to the default
|
||||
LiteLLM proxy URL. This is intentional behavior for "basic" settings where users
|
||||
don't specify a custom base URL.
|
||||
"""
|
||||
"""Test store_llm_settings with partial update."""
|
||||
settings = Settings(
|
||||
llm_model='gpt-4' # Only updating model
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
@@ -203,19 +207,15 @@ async def test_store_llm_settings_partial_update():
|
||||
llm_base_url='https://existing.example.com',
|
||||
)
|
||||
|
||||
result = await store_llm_settings(settings, existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
# Should return settings with updated model but keep API key
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with updated model but keep other values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
# For SecretStr objects, we need to compare the secret value
|
||||
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
|
||||
# When llm_base_url is not provided, it defaults to the LiteLLM proxy URL
|
||||
import os
|
||||
|
||||
expected_base_url = os.environ.get(
|
||||
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
|
||||
)
|
||||
assert result.llm_base_url == expected_base_url
|
||||
assert result.llm_base_url == 'https://existing.example.com'
|
||||
|
||||
|
||||
# Tests for store_provider_tokens
|
||||
|
||||
Reference in New Issue
Block a user