Merge branch 'main' into ALL-2596/org-support

This commit is contained in:
sp.wack 2025-12-09 22:38:38 +04:00 committed by GitHub
commit fc3dd517df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1481 additions and 512 deletions

View File

@ -22,6 +22,7 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
@ -164,8 +165,13 @@ class GithubManager(Manager):
)
if await self.is_job_requested(message):
payload = message.message.get('payload', {})
user_id = payload['sender']['id']
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
github_view = await GithubFactory.create_github_view_from_payload(
message, self.token_manager
message, keycloak_user_id
)
logger.info(
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
@ -282,8 +288,15 @@ class GithubManager(Manager):
f'[Github]: Error summarizing issue solvability: {str(e)}'
)
saas_user_auth = await get_saas_user_auth(
github_view.user_info.keycloak_user_id, self.token_manager
)
await github_view.create_new_conversation(
self.jinja_env, secret_store.provider_tokens, convo_metadata
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
conversation_id = github_view.conversation_id
@ -292,14 +305,7 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
from openhands.server.shared import ConversationStoreImpl, config
conversation_store = await ConversationStoreImpl.get_instance(
config, github_view.user_info.keycloak_user_id
)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.conversation_version != 'v1':
if not github_view.v1:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,

View File

@ -1,3 +1,4 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from github import Github, GithubIntegration
@ -8,6 +9,7 @@ from integrations.github.github_types import (
WorkflowRunStatus,
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@ -17,7 +19,6 @@ from integrations.utils import (
has_exact_mention,
)
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
@ -34,18 +35,16 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
@ -55,52 +54,6 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class GithubUserContext(UserContext):
"""User context for GitHub integration that provides user info without web request."""
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
self.keycloak_user_id = keycloak_user_id
self.git_provider_tokens = git_provider_tokens
self.settings_store = SaasSettingsStore(
user_id=self.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
self.secrets_store = SaasSecretsStore(
self.keycloak_user_id, session_maker, get_config()
)
async def get_user_id(self) -> str | None:
return self.keycloak_user_id
async def get_user_info(self) -> UserInfo:
user_settings = await self.settings_store.load()
return UserInfo(
id=self.keycloak_user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
return self.git_provider_tokens.get(ProviderType.GITHUB)
return None
async def get_secrets(self) -> dict[str, SecretSource]:
# Return empty dict for now - GitHub integration handles secrets separately
user_secrets = await self.secrets_store.load()
return dict(user_secrets.custom_secrets) if user_secrets else {}
async def get_mcp_api_key(self) -> str | None:
raise NotImplementedError()
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
@ -134,7 +87,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
return settings.enable_proactive_conversation_starters
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
async def get_user_v1_enabled_setting(user_id: str) -> bool:
"""Get the user's V1 conversation API setting.
Args:
@ -143,11 +96,6 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
@ -183,6 +131,7 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1: bool
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@ -229,6 +178,19 @@ class GithubIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
@ -245,14 +207,17 @@ class GithubIssue(ResolverViewInterface):
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, git_provider_tokens, conversation_metadata
jinja_env, saas_user_auth, conversation_metadata
)
return
@ -271,6 +236,7 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub V1]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
@ -292,10 +258,12 @@ class GithubIssue(ResolverViewInterface):
async def _create_v1_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
saas_user_auth: UserAuth,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[GitHub V1]: Creating V1 conversation')
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
@ -326,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = GithubUserContext(
keycloak_user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
)
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@ -344,6 +309,8 @@ class GithubIssue(ResolverViewInterface):
f'Failed to start V1 conversation: {task.detail}'
)
self.v1 = True
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
@ -806,7 +773,7 @@ class GithubFactory:
@staticmethod
async def create_github_view_from_payload(
message: Message, token_manager: TokenManager
message: Message, keycloak_user_id: str
) -> ResolverViewInterface:
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
Also return metadata about the event (e.g., action type).
@ -816,17 +783,10 @@ class GithubFactory:
user_id = payload['sender']['id']
username = payload['sender']['login']
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
if keyloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
is_public_repo = not repo_obj.get('private', True)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
)
installation_id = message.message['installation']
@ -850,6 +810,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_issue_comment(message):
@ -875,6 +836,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_pr_comment(message):
@ -916,6 +878,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@ -949,6 +912,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
else:

View File

@ -0,0 +1,63 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
class ResolverUserContext(UserContext):
"""User context for resolver operations that inherits from UserContext."""
def __init__(
self,
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
async def get_user_info(self) -> UserInfo:
user_settings = await self.saas_user_auth.get_user_settings()
user_id = await self.saas_user_auth.get_user_id()
if user_settings:
return UserInfo(
id=user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
return UserInfo(id=user_id)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens:
return provider_tokens.get(provider_type)
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:
"""Get secrets for the user, including custom secrets."""
secrets = await self.saas_user_auth.get_secrets()
if secrets:
# Convert custom secrets to StaticSecret objects for SDK compatibility
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
converted_secrets = {}
for key, custom_secret in secrets.custom_secrets.items():
# Extract the secret value from CustomSecret and convert to StaticSecret
secret_value = custom_secret.secret.get_secret_value()
converted_secrets[key] = StaticSecret(value=secret_value)
return converted_secrets
return {}
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()

View File

@ -19,7 +19,7 @@ class PRStatus(Enum):
class UserData(BaseModel):
user_id: int
username: str
keycloak_user_id: str | None
keycloak_user_id: str
@dataclass

View File

@ -0,0 +1,20 @@
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import UserAuth
async def get_saas_user_auth(
keycloak_user_id: str, token_manager: TokenManager
) -> UserAuth:
offline_token = await token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth

View File

@ -1,3 +1,4 @@
import asyncio
import hashlib
import hmac
import os
@ -58,7 +59,8 @@ async def github_events(
)
try:
payload = await request.body()
# Add timeout to prevent hanging on slow/stalled clients
payload = await asyncio.wait_for(request.body(), timeout=15.0)
verify_github_signature(payload, x_hub_signature_256)
payload_data = await request.json()
@ -78,6 +80,12 @@ async def github_events(
status_code=200,
content={'message': 'GitHub events endpoint reached successfully.'},
)
except asyncio.TimeoutError:
logger.warning('GitHub webhook request timed out waiting for request body')
return JSONResponse(
status_code=408,
content={'error': 'Request timeout - client took too long to send data.'},
)
except Exception as e:
logger.exception(f'Error processing GitHub event: {e}')
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})

View File

@ -0,0 +1,133 @@
"""Test for ResolverUserContext get_secrets conversion logic.
This test focuses on testing the actual ResolverUserContext implementation.
"""
from types import MappingProxyType
from unittest.mock import AsyncMock
import pytest
from pydantic import SecretStr
from enterprise.integrations.resolver_context import ResolverUserContext
# Import the real classes we want to test
from openhands.integrations.provider import CustomSecret
# Import the SDK types we need for testing
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
from openhands.storage.data_models.secrets import Secrets
@pytest.fixture
def mock_saas_user_auth():
"""Mock SaasUserAuth for testing."""
return AsyncMock()
@pytest.fixture
def resolver_context(mock_saas_user_auth):
"""Create a ResolverUserContext instance for testing."""
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
"""Helper to create CustomSecret instances."""
return CustomSecret(secret=SecretStr(value), description=description)
def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets:
"""Helper to create Secrets instances."""
return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict))
@pytest.mark.asyncio
async def test_get_secrets_converts_custom_to_static(
resolver_context, mock_saas_user_auth
):
"""Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects."""
# Arrange
secrets = create_secrets(
{
'TEST_SECRET_1': create_custom_secret('secret_value_1'),
'TEST_SECRET_2': create_custom_secret('secret_value_2'),
}
)
mock_saas_user_auth.get_secrets.return_value = secrets
# Act
result = await resolver_context.get_secrets()
# Assert
assert len(result) == 2
assert all(isinstance(secret, StaticSecret) for secret in result.values())
assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1'
assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2'
@pytest.mark.asyncio
async def test_get_secrets_with_special_characters(
resolver_context, mock_saas_user_auth
):
"""Test that secret values with special characters are preserved during conversion."""
# Arrange
special_value = 'very_secret_password_123!@#$%^&*()'
secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)})
mock_saas_user_auth.get_secrets.return_value = secrets
# Act
result = await resolver_context.get_secrets()
# Assert
assert len(result) == 1
assert isinstance(result['SPECIAL_SECRET'], StaticSecret)
assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value
@pytest.mark.asyncio
@pytest.mark.parametrize(
'secrets_input,expected_result',
[
(None, {}), # No secrets available
(create_secrets({}), {}), # Empty custom secrets
],
)
async def test_get_secrets_empty_cases(
resolver_context, mock_saas_user_auth, secrets_input, expected_result
):
"""Test that get_secrets handles empty cases correctly."""
# Arrange
mock_saas_user_auth.get_secrets.return_value = secrets_input
# Act
result = await resolver_context.get_secrets()
# Assert
assert result == expected_result
def test_static_secret_is_valid_secret_source():
"""Test that StaticSecret is a valid SecretSource for SDK validation."""
# Arrange & Act
static_secret = StaticSecret(value='test_secret_123')
# Assert
assert isinstance(static_secret, StaticSecret)
assert isinstance(static_secret, SecretSource)
assert static_secret.value.get_secret_value() == 'test_secret_123'
def test_custom_to_static_conversion():
"""Test the complete conversion flow from CustomSecret to StaticSecret."""
# Arrange
secret_value = 'conversion_test_secret'
custom_secret = create_custom_secret(secret_value, 'Conversion test')
# Act - simulate the conversion logic from the actual method
extracted_value = custom_secret.secret.get_secret_value()
static_secret = StaticSecret(value=extracted_value)
# Assert
assert isinstance(static_secret, StaticSecret)
assert isinstance(static_secret, SecretSource)
assert static_secret.value.get_secret_value() == secret_value

View File

@ -1,6 +1,7 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
import pytest
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
from integrations.models import Message, SourceType
from integrations.types import UserData
@ -114,8 +115,10 @@ class TestGithubV1ConversationRouting(TestCase):
title='Test Issue',
description='Test issue description',
previous_comments=[],
v1=False,
)
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
@ -144,6 +147,7 @@ class TestGithubV1ConversationRouting(TestCase):
)
mock_create_v1.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
@ -172,6 +176,7 @@ class TestGithubV1ConversationRouting(TestCase):
)
mock_create_v0.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')

View File

@ -61,7 +61,7 @@ describe("ExpandableMessage", () => {
expect(icon).toHaveClass("fill-success");
});
it("should render with error icon for failed action messages", () => {
it("should render with no icon for failed action messages", () => {
renderWithProviders(
<ExpandableMessage
id="OBSERVATION_MESSAGE$RUN"
@ -75,8 +75,7 @@ describe("ExpandableMessage", () => {
"div.flex.gap-2.items-center.justify-start",
);
expect(container).toHaveClass("border-neutral-300");
const icon = screen.getByTestId("status-icon");
expect(icon).toHaveClass("fill-danger");
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});
it("should render with neutral border and no icon for action messages without success prop", () => {

View File

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => {

View File

@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
@ -404,7 +404,7 @@ describe("RepoConnector", () => {
ConversationService,
"createConversation",
);
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",

View File

@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.

View File

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";

View File

@ -1,12 +1,26 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll,
afterEach,
} from "vitest";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useBrowserStore } from "#/stores/browser-store";
import { useCommandStore } from "#/state/command-store";
import {
createMockMessageEvent,
createMockUserMessageEvent,
createMockAgentErrorEvent,
createMockBrowserObservationEvent,
createMockBrowserNavigateActionEvent,
createMockExecuteBashActionEvent,
createMockExecuteBashObservationEvent,
} from "#/mocks/mock-ws-helpers";
import {
ConnectionStatusComponent,
@ -461,7 +475,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
const { events } = useEventStore();
@ -474,7 +488,7 @@ describe("Conversation WebSocket Handler", () => {
<div data-testid="expected-event-count">{expectedEventCount}</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@ -484,7 +498,9 @@ describe("Conversation WebSocket Handler", () => {
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"true",
);
// Wait for all events to be received
await waitFor(() => {
@ -523,7 +539,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
return (
@ -533,7 +549,7 @@ describe("Conversation WebSocket Handler", () => {
</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@ -583,7 +599,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
const { events } = useEventStore();
@ -595,7 +611,7 @@ describe("Conversation WebSocket Handler", () => {
<div data-testid="events-received">{events.length}</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@ -605,7 +621,9 @@ describe("Conversation WebSocket Handler", () => {
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"true",
);
// Wait for all events to be received
await waitFor(() => {
@ -621,17 +639,133 @@ describe("Conversation WebSocket Handler", () => {
});
});
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
it("should append command to store when ExecuteBashAction event is received", async () => {
const { createMockExecuteBashActionEvent } = await import(
"#/mocks/mock-ws-helpers"
// 9. Browser State Tests (BrowserObservation)
describe("Browser State Integration", () => {
beforeEach(() => {
useBrowserStore.getState().reset();
});
it("should update browser store with screenshot when BrowserObservation event is received", async () => {
// Create a mock BrowserObservation event with screenshot data
const mockBrowserObsEvent = createMockBrowserObservationEvent(
"base64-screenshot-data",
"Page loaded successfully",
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the browser store to be updated with screenshot
await waitFor(() => {
const { screenshotSrc } = useBrowserStore.getState();
expect(screenshotSrc).toBe(
"data:image/png;base64,base64-screenshot-data",
);
});
});
it("should update browser store with URL when BrowserNavigateAction followed by BrowserObservation", async () => {
// Create mock events - action first, then observation
const mockBrowserActionEvent = createMockBrowserNavigateActionEvent(
"https://example.com/test-page",
);
const mockBrowserObsEvent = createMockBrowserObservationEvent(
"base64-screenshot-data",
"Page loaded successfully",
);
// Set up MSW to send both events when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send action first, then observation
client.send(JSON.stringify(mockBrowserActionEvent));
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the browser store to be updated with both screenshot and URL
await waitFor(() => {
const { screenshotSrc, url } = useBrowserStore.getState();
expect(screenshotSrc).toBe(
"data:image/png;base64,base64-screenshot-data",
);
expect(url).toBe("https://example.com/test-page");
});
});
it("should not update browser store when BrowserObservation has no screenshot data", async () => {
const initialScreenshot = useBrowserStore.getState().screenshotSrc;
// Create a mock BrowserObservation event WITHOUT screenshot data
const mockBrowserObsEvent = createMockBrowserObservationEvent(
null, // no screenshot
"Browser action completed",
);
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Give some time for any potential updates
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
// Screenshot should remain unchanged (empty/initial value)
const { screenshotSrc } = useBrowserStore.getState();
expect(screenshotSrc).toBe(initialScreenshot);
});
});
// 10. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
beforeEach(() => {
useCommandStore.getState().clearTerminal();
});
it("should append command to store when ExecuteBashAction event is received", async () => {
// Create a mock ExecuteBashAction event
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
@ -667,14 +801,6 @@ describe("Conversation WebSocket Handler", () => {
});
it("should append output to store when ExecuteBashObservation event is received", async () => {
const { createMockExecuteBashObservationEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashObservation event
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
"PASS tests/example.test.js\n ✓ should work (2 ms)",

View File

@ -1,7 +1,7 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
describe("useSaveSettings", () => {

View File

@ -1,3 +1,11 @@
/**
* TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944)
*
* Several tests in this file are skipped because they fail intermittently in CI
* but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages
* to ALL connected clients across all tests, causing cross-test contamination
* when tests run in parallel with Vitest v4.
*/
import { renderHook, waitFor } from "@testing-library/react";
import {
describe,
@ -51,7 +59,7 @@ describe("useWebSocket", () => {
expect(result.current.socket).toBeTruthy();
});
it("should handle incoming messages correctly", async () => {
it.skip("should handle incoming messages correctly", async () => {
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
// Wait for connection to be established
@ -114,7 +122,7 @@ describe("useWebSocket", () => {
expect(result.current.socket).toBeTruthy();
});
it("should close the WebSocket connection on unmount", async () => {
it.skip("should close the WebSocket connection on unmount", async () => {
const { result, unmount } = renderHook(() =>
useWebSocket("ws://acme.com/ws"),
);
@ -204,7 +212,7 @@ describe("useWebSocket", () => {
});
});
it("should call onMessage handler when WebSocket receives a message", async () => {
it.skip("should call onMessage handler when WebSocket receives a message", async () => {
const onMessageSpy = vi.fn();
const options = { onMessage: onMessageSpy };
@ -271,7 +279,7 @@ describe("useWebSocket", () => {
expect(onErrorSpy).toHaveBeenCalled();
});
it("should provide sendMessage function to send messages to WebSocket", async () => {
it.skip("should provide sendMessage function to send messages to WebSocket", async () => {
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
// Wait for connection to be established

View File

@ -10,7 +10,7 @@ import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import OptionService from "#/api/option-service/option-service.api";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("frontend/routes/_oh", () => {

View File

@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";

View File

@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
import { createAxiosNotFoundErrorObject } from "test-utils";
import HomeScreen from "#/routes/home";
import { GitRepository } from "#/types/git";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import MainApp from "#/routes/root-layout";

View File

@ -3,13 +3,14 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import OptionService from "#/api/option-service/option-service.api";
// Mock react-router hooks
const mockUseSearchParams = vi.fn();
@ -255,6 +256,210 @@ describe("Content", () => {
});
it.todo("should render an indicator if the llm api key is set");
describe("API key visibility in Basic Settings", () => {
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Verify OpenHands is selected by default
await waitFor(() => {
expect(provider).toHaveValue("OpenHands");
});
// API key input should not be visible when OpenHands provider is selected in SaaS mode
expect(
within(basicForm).queryByTestId("llm-api-key-input"),
).not.toBeInTheDocument();
expect(
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
).not.toBeInTheDocument();
});
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Select OpenAI provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
});
// API key input should be visible when non-OpenHands provider is selected in SaaS mode
expect(
within(basicForm).getByTestId("llm-api-key-input"),
).toBeInTheDocument();
expect(
within(basicForm).getByTestId("llm-api-key-help-anchor"),
).toBeInTheDocument();
});
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Verify OpenHands is selected by default
await waitFor(() => {
expect(provider).toHaveValue("OpenHands");
});
// API key input should be visible when OSS mode is enabled (even with OpenHands provider)
expect(
within(basicForm).getByTestId("llm-api-key-input"),
).toBeInTheDocument();
expect(
within(basicForm).getByTestId("llm-api-key-help-anchor"),
).toBeInTheDocument();
});
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Select OpenAI provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
});
// API key input should be visible when OSS mode is enabled
expect(
within(basicForm).getByTestId("llm-api-key-input"),
).toBeInTheDocument();
expect(
within(basicForm).getByTestId("llm-api-key-help-anchor"),
).toBeInTheDocument();
});
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Start with OpenAI provider
await userEvent.click(provider);
const openAIOption = screen.getByText("OpenAI");
await userEvent.click(openAIOption);
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
});
// API key input should be visible with OpenAI
expect(
within(basicForm).getByTestId("llm-api-key-input"),
).toBeInTheDocument();
// Switch to OpenHands provider
await userEvent.click(provider);
const openHandsOption = screen.getByText("OpenHands");
await userEvent.click(openHandsOption);
await waitFor(() => {
expect(provider).toHaveValue("OpenHands");
});
// API key input should now be hidden
expect(
within(basicForm).queryByTestId("llm-api-key-input"),
).not.toBeInTheDocument();
expect(
within(basicForm).queryByTestId("llm-api-key-help-anchor"),
).not.toBeInTheDocument();
});
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return APP_MODE for these tests
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicForm = screen.getByTestId("llm-settings-form-basic");
const provider = within(basicForm).getByTestId("llm-provider-input");
// Verify OpenHands is selected by default
await waitFor(() => {
expect(provider).toHaveValue("OpenHands");
});
// API key input should be hidden with OpenHands
expect(
within(basicForm).queryByTestId("llm-api-key-input"),
).not.toBeInTheDocument();
// Switch to OpenAI provider
await userEvent.click(provider);
const openAIOption = screen.getByText("OpenAI");
await userEvent.click(openAIOption);
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
});
// API key input should now be visible
expect(
within(basicForm).getByTestId("llm-api-key-input"),
).toBeInTheDocument();
expect(
within(basicForm).getByTestId("llm-api-key-help-anchor"),
).toBeInTheDocument();
});
});
});
describe("Form submission", () => {

View File

@ -6,7 +6,7 @@ import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";

File diff suppressed because it is too large Load Diff

View File

@ -11,17 +11,17 @@
"@heroui/use-infinite-scroll": "^2.2.12",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.5.0",
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
"@posthog/react": "^1.5.2",
"@react-router/node": "^7.10.1",
"@react-router/serve": "^7.10.1",
"@react-types/shared": "^3.32.0",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.5.3",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query": "^5.90.12",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^5.1.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.13.2",
@ -31,22 +31,22 @@
"downshift": "^9.0.12",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.25",
"i18next": "^25.7.1",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.32",
"jose": "^6.1.3",
"lucide-react": "^0.555.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.299.0",
"lucide-react": "^0.556.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.302.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.3.5",
"react-i18next": "^16.4.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.9.6",
"react-router": "^7.10.1",
"react-syntax-highlighter": "^16.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@ -54,7 +54,7 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0",
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.2.6",
"vite": "^7.2.7",
"web-vitals": "^5.1.0",
"ws": "^8.18.2",
"zustand": "^5.0.9"
@ -97,7 +97,7 @@
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.57.0",
"@react-router/dev": "^7.9.6",
"@react-router/dev": "^7.10.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.0",
"@testing-library/dom": "^10.4.1",
@ -127,7 +127,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.2.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
"msw": "^2.6.6",
"prettier": "^3.7.3",

View File

@ -1,4 +1,4 @@
import { openHands } from "../api/open-hands-axios";
import { openHands } from "../open-hands-axios";
import { ApiSettings, PostApiSettings } from "./settings.types";
/**

View File

@ -22,6 +22,13 @@ const getCommandObservationContent = (
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
const command = event.observation === "run" ? event.extras.command : null;
if (command) {
return `Command:\n\`\`\`sh\n${command}\n\`\`\`\n\nOutput:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
}
return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
};

View File

@ -6,7 +6,6 @@ import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import XCircle from "#/icons/x-circle-solid.svg?react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { cn } from "#/utils/utils";
@ -169,19 +168,12 @@ export function ExpandableMessage({
)}
</button>
</span>
{type === "action" && success !== undefined && (
{type === "action" && success && (
<span className="flex-shrink-0">
{success ? (
<CheckCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-success")}
/>
) : (
<XCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-danger")}
/>
)}
<CheckCircle
data-testid="status-icon"
className={cn(statusIconClasses, "fill-success")}
/>
</span>
)}
</div>

View File

@ -1,6 +1,5 @@
import { FaClock } from "react-icons/fa";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import XCircle from "#/icons/x-circle-solid.svg?react";
import { ObservationResultStatus } from "./event-content-helpers/get-observation-result";
interface SuccessIndicatorProps {
@ -17,13 +16,6 @@ export function SuccessIndicator({ status }: SuccessIndicatorProps) {
/>
)}
{status === "error" && (
<XCircle
data-testid="status-icon"
className="h-4 w-4 ml-2 inline fill-danger"
/>
)}
{status === "timeout" && (
<FaClock
data-testid="status-icon"

View File

@ -19,8 +19,10 @@ import {
} from "#/state/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { useConversationId } from "#/hooks/use-conversation-id";
export function ConversationTabs() {
const { conversationId } = useConversationId();
const {
selectedTab,
isRightPanelShown,
@ -30,18 +32,21 @@ export function ConversationTabs() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Persist selectedTab and isRightPanelShown in localStorage
// Persist selectedTab and isRightPanelShown in localStorage per conversation
const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>(
"conversation-selected-tab",
`conversation-selected-tab-${conversationId}`,
"editor",
);
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true);
useLocalStorage<boolean>(
`conversation-right-panel-shown-${conversationId}`,
true,
);
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
`conversation-unpinned-tabs-${conversationId}`,
[],
);

View File

@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { getObservationContent } from "../get-observation-content";
import { ObservationEvent } from "#/types/v1/core";
import { BrowserObservation } from "#/types/v1/core/base/observation";
describe("getObservationContent - BrowserObservation", () => {
it("should return output content when available", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "Browser action completed",
error: null,
screenshot_data: "base64data",
},
};
const result = getObservationContent(mockEvent);
expect(result).toContain("**Output:**");
expect(result).toContain("Browser action completed");
});
it("should handle error cases properly", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "",
error: "Browser action failed",
screenshot_data: null,
},
};
const result = getObservationContent(mockEvent);
expect(result).toContain("**Error:**");
expect(result).toContain("Browser action failed");
});
it("should provide default message when no output or error", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "",
error: null,
screenshot_data: "base64data",
},
};
const result = getObservationContent(mockEvent);
expect(result).toBe("Browser action completed successfully.");
});
it("should return output when screenshot_data is null", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "Page loaded successfully",
error: null,
screenshot_data: null,
},
};
const result = getObservationContent(mockEvent);
expect(result).toBe("**Output:**\nPage loaded successfully");
});
});

View File

@ -98,14 +98,16 @@ const getBrowserObservationContent = (
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n")
: "";
: observation.output || "";
let contentDetails = "";
if ("is_error" in observation && observation.is_error) {
contentDetails += `**Error:**\n${textContent}`;
} else {
if (observation.error) {
contentDetails += `**Error:**\n${observation.error}`;
} else if (textContent) {
contentDetails += `**Output:**\n${textContent}`;
} else {
contentDetails += "Browser action completed successfully.";
}
if (contentDetails.length > MAX_CONTENT_LENGTH) {

View File

@ -14,6 +14,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useCommandStore } from "#/state/command-store";
import { useBrowserStore } from "#/stores/browser-store";
import {
isV1Event,
isAgentErrorEvent,
@ -27,6 +28,8 @@ import {
isExecuteBashObservationEvent,
isConversationErrorEvent,
isPlanningFileEditorObservationEvent,
isBrowserObservationEvent,
isBrowserNavigateActionEvent,
} from "#/types/v1/type-guards";
import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
@ -383,6 +386,22 @@ export function ConversationWebSocketProvider({
.join("\n");
appendOutput(textContent);
}
// Handle BrowserObservation events - update browser store with screenshot
if (isBrowserObservationEvent(event)) {
const { screenshot_data: screenshotData } = event.observation;
if (screenshotData) {
const screenshotSrc = screenshotData.startsWith("data:")
? screenshotData
: `data:image/png;base64,${screenshotData}`;
useBrowserStore.getState().setScreenshotSrc(screenshotSrc);
}
}
// Handle BrowserNavigateAction events - update browser store with URL
if (isBrowserNavigateActionEvent(event)) {
useBrowserStore.getState().setUrl(event.action.url);
}
}
} catch (error) {
// eslint-disable-next-line no-console

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
@ -57,6 +57,7 @@ export function useAddMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
};
await SettingsService.saveSettings(apiSettings);

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
export function useDeleteMcpServer() {
@ -25,6 +25,7 @@ export function useDeleteMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
};
await SettingsService.saveSettings(apiSettings);

View File

@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
import { PostApiSettings } from "#/settings-service/settings.types";
import { PostApiSettings } from "#/api/settings-service/settings.types";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
@ -59,6 +59,7 @@ export function useUpdateMcpServer() {
const apiSettings = {
mcp_config: newConfig,
v1_enabled: settings.V1_ENABLED,
};
await SettingsService.saveSettings(apiSettings);

View File

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";

View File

@ -7,7 +7,7 @@ import { Provider } from "#/types/settings";
import {
ApiSettings,
PostApiSettings,
} from "#/settings-service/settings.types";
} from "#/api/settings-service/settings.types";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";

View File

@ -184,3 +184,55 @@ export const createMockExecuteBashObservationEvent = (
},
action_id: "bash-action-123",
});
/**
* Creates a mock BrowserObservation event for testing browser state handling
*/
export const createMockBrowserObservationEvent = (
screenshotData: string | null = "base64-screenshot-data",
output: string = "Browser action completed",
error: string | null = null,
) => ({
id: "browser-obs-123",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "browser-call-456",
observation: {
kind: "BrowserObservation",
output,
error,
screenshot_data: screenshotData,
},
action_id: "browser-action-123",
});
/**
* Creates a mock BrowserNavigateAction event for testing browser URL extraction
*/
export const createMockBrowserNavigateActionEvent = (
url: string = "https://example.com",
) => ({
id: "browser-action-123",
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Navigating to URL" }],
thinking_blocks: [],
action: {
kind: "BrowserNavigateAction",
url,
new_tab: false,
},
tool_name: "browser_navigate",
tool_call_id: "browser-call-456",
tool_call: {
id: "browser-call-456",
type: "function",
function: {
name: "browser_navigate",
arguments: JSON.stringify({ url, new_tab: false }),
},
},
llm_response_id: "llm-response-789",
security_risk: { level: "low" },
});

View File

@ -239,18 +239,20 @@ function AppSettingsScreen() {
</SettingsSwitch>
)}
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
{!settings?.V1_ENABLED && (
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
)}
<div className="border-t border-t-tertiary pt-6 mt-2">
<h3 className="text-lg font-medium mb-2">

View File

@ -112,11 +112,25 @@ function LlmSettingsScreen() {
// Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode)
const currentModel = currentSelectedModel || settings?.LLM_MODEL;
const isOpenHandsProvider =
(view === "basic" && selectedProvider === "openhands") ||
(view === "advanced" && currentModel?.startsWith("openhands/"));
const isSaasMode = config?.APP_MODE === "saas";
const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode;
const isOpenHandsProvider = () => {
if (view === "basic") {
return selectedProvider === "openhands";
}
if (view === "advanced") {
if (dirtyInputs.model) {
return currentModel?.startsWith("openhands/");
}
return settings?.LLM_MODEL?.startsWith("openhands/");
}
return false;
};
const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode;
// Determine if we should hide the agent dropdown when V1 conversation API is enabled
const isV1Enabled = settings?.V1_ENABLED;

View File

@ -61,10 +61,48 @@ interface ConversationActions {
type ConversationStore = ConversationState & ConversationActions;
// Helper function to get initial right panel state from localStorage
const getConversationIdFromLocation = (): string | null => {
if (typeof window === "undefined") {
return null;
}
const match = window.location.pathname.match(/\/conversations\/([^/]+)/);
return match ? match[1] : null;
};
const parseStoredBoolean = (value: string | null): boolean | null => {
if (value === null) {
return null;
}
try {
return JSON.parse(value);
} catch {
return null;
}
};
const getInitialRightPanelState = (): boolean => {
const stored = localStorage.getItem("conversation-right-panel-shown");
return stored !== null ? JSON.parse(stored) : true;
if (typeof window === "undefined") {
return true;
}
const conversationId = getConversationIdFromLocation();
const keysToCheck = conversationId
? [`conversation-right-panel-shown-${conversationId}`]
: [];
// Fallback to legacy global key for users who haven't switched tabs yet
keysToCheck.push("conversation-right-panel-shown");
for (const key of keysToCheck) {
const parsed = parseStoredBoolean(localStorage.getItem(key));
if (parsed !== null) {
return parsed;
}
}
return true;
};
export const useConversationStore = create<ConversationStore>()(

View File

@ -7,6 +7,8 @@ import {
ExecuteBashObservation,
PlanningFileEditorObservation,
TerminalObservation,
BrowserObservation,
BrowserNavigateAction,
} from "./core";
import { AgentErrorEvent } from "./core/events/observation-event";
import { MessageEvent } from "./core/events/message-event";
@ -126,6 +128,22 @@ export const isPlanningFileEditorObservationEvent = (
isObservationEvent(event) &&
event.observation.kind === "PlanningFileEditorObservation";
/**
* Type guard function to check if an observation event is a BrowserObservation
*/
export const isBrowserObservationEvent = (
event: OpenHandsEvent,
): event is ObservationEvent<BrowserObservation> =>
isObservationEvent(event) && event.observation.kind === "BrowserObservation";
/**
* Type guard function to check if an action event is a BrowserNavigateAction
*/
export const isBrowserNavigateActionEvent = (
event: OpenHandsEvent,
): event is ActionEvent<BrowserNavigateAction> =>
isActionEvent(event) && event.action.kind === "BrowserNavigateAction";
/**
* Type guard function to check if an event is a system prompt event
*/

View File

@ -2,8 +2,7 @@
This directory contains the core components of OpenHands.
This diagram provides an overview of the roles of each component and how they communicate and collaborate.
![OpenHands System Architecture Diagram (July 4, 2024)](../docs/static/img/system_architecture_overview.png)
For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture).
## Classes

View File

@ -102,6 +102,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
sandbox_startup_poll_frequency: int
httpx_client: httpx.AsyncClient
web_url: str | None
openhands_provider_base_url: str | None
access_token_hard_timeout: timedelta | None
app_mode: str | None = None
keycloak_auth_cookie: str | None = None
@ -526,13 +527,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model
async def _setup_secrets_for_git_provider(
self, git_provider: ProviderType | None, user: UserInfo
) -> dict:
"""Set up secrets for git provider authentication.
async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
"""Set up secrets for all git provider authentication.
Args:
git_provider: The git provider type (GitHub, GitLab, etc.)
user: User information containing authentication details
Returns:
@ -540,35 +538,42 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
"""
secrets = await self.user_context.get_secrets()
if not git_provider:
# Get all provider tokens from user authentication
provider_tokens = await self.user_context.get_provider_tokens()
if not provider_tokens:
return secrets
secret_name = f'{git_provider.name}_TOKEN'
# Create secrets for each provider token
for provider_type, provider_token in provider_tokens.items():
if not provider_token.token:
continue
if self.web_url:
# Create an access token for web-based authentication
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
'provider_type': git_provider.value,
},
expires_in=self.access_token_hard_timeout,
)
headers = {'X-Access-Token': access_token}
secret_name = f'{provider_type.name}_TOKEN'
# Include keycloak_auth cookie in headers if app_mode is SaaS
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
if self.web_url:
# Create an access token for web-based authentication
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
'provider_type': provider_type.value,
},
expires_in=self.access_token_hard_timeout,
)
headers = {'X-Access-Token': access_token}
secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
headers=headers,
)
else:
# Use static token for environments without web URL access
static_token = await self.user_context.get_latest_token(git_provider)
if static_token:
secrets[secret_name] = StaticSecret(value=static_token)
# Include keycloak_auth cookie in headers if app_mode is SaaS
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
headers=headers,
)
else:
# Use static token for environments without web URL access
static_token = await self.user_context.get_latest_token(provider_type)
if static_token:
secrets[secret_name] = StaticSecret(value=static_token)
return secrets
@ -586,9 +591,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
"""
# Configure LLM
model = llm_model or user.llm_model
base_url = user.llm_base_url
if model and model.startswith('openhands/'):
base_url = user.llm_base_url or self.openhands_provider_base_url
llm = LLM(
model=model,
base_url=user.llm_base_url,
base_url=base_url,
api_key=user.llm_api_key,
usage_id='agent',
)
@ -768,8 +776,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
user = await self.user_context.get_user_info()
workspace = LocalWorkspace(working_dir=working_dir)
# Set up secrets for git provider
secrets = await self._setup_secrets_for_git_provider(git_provider, user)
# Set up secrets for all git providers
secrets = await self._setup_secrets_for_git_providers(user)
# Configure LLM and MCP
llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model)
@ -1078,6 +1086,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
httpx_client=httpx_client,
web_url=web_url,
openhands_provider_base_url=config.openhands_provider_base_url,
access_token_hard_timeout=access_token_hard_timeout,
app_mode=app_mode,
keycloak_auth_cookie=keycloak_auth_cookie,

View File

@ -74,6 +74,11 @@ def get_default_web_url() -> str | None:
return f'https://{web_host}'
def get_openhands_provider_base_url() -> str | None:
"""Return the base URL for the OpenHands provider, if configured."""
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
def _get_default_lifespan():
# Check legacy parameters for saas mode. If we are in SAAS mode do not apply
# OSS alembic migrations
@ -88,6 +93,10 @@ class AppServerConfig(OpenHandsModel):
default_factory=get_default_web_url,
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
)
openhands_provider_base_url: str | None = Field(
default_factory=get_openhands_provider_base_url,
description='Base URL for the OpenHands provider',
)
# Dependency Injection Injectors
event: EventServiceInjector | None = None
event_callback: EventCallbackServiceInjector | None = None

View File

@ -60,16 +60,22 @@ _logger = logging.getLogger(__name__)
async def valid_sandbox(
sandbox_id: str,
user_context: UserContext = Depends(as_admin),
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
if session_api_key is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
)
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
)
return sandbox_info
@ -94,7 +100,7 @@ async def valid_conversation(
return app_conversation_info
@router.post('/{sandbox_id}/conversations')
@router.post('/conversations')
async def on_conversation_update(
conversation_info: ConversationInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
@ -125,7 +131,7 @@ async def on_conversation_update(
return Success()
@router.post('/{sandbox_id}/events/{conversation_id}')
@router.post('/events/{conversation_id}')
async def on_event(
events: list[Event],
conversation_id: UUID,

View File

@ -260,6 +260,29 @@ class DockerSandboxService(SandboxService):
except (NotFound, APIError):
return None
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key."""
try:
# Get all containers with our prefix
all_containers = self.docker_client.containers.list(all=True)
for container in all_containers:
if container.name and container.name.startswith(
self.container_name_prefix
):
# Check if this container has the matching session API key
env_vars = self._get_container_env_vars(container)
container_session_key = env_vars.get(SESSION_API_KEY_VARIABLE)
if container_session_key == session_api_key:
return await self._container_to_checked_sandbox_info(container)
return None
except (NotFound, APIError):
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Enforce sandbox limits by cleaning up old sandboxes
@ -285,8 +308,7 @@ class DockerSandboxService(SandboxService):
env_vars = sandbox_spec.initial_env.copy()
env_vars[SESSION_API_KEY_VARIABLE] = session_api_key
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (
f'http://host.docker.internal:{self.host_port}'
f'/api/v1/webhooks/{container_name}'
f'http://host.docker.internal:{self.host_port}/api/v1/webhooks'
)
# Prepare port mappings and add port environment variables

View File

@ -275,6 +275,17 @@ class ProcessSandboxService(SandboxService):
return await self._process_to_sandbox_info(sandbox_id, process_info)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key."""
# Search through all processes to find one with matching session_api_key
for sandbox_id, process_info in _processes.items():
if process_info.session_api_key == session_api_key:
return await self._process_to_sandbox_info(sandbox_id, process_info)
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Get sandbox spec

View File

@ -240,9 +240,7 @@ class RemoteSandboxService(SandboxService):
# If a public facing url is defined, add a callback to the agent server environment.
if self.web_url:
environment[WEBHOOK_CALLBACK_VARIABLE] = (
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
)
environment[WEBHOOK_CALLBACK_VARIABLE] = f'{self.web_url}/api/v1/webhooks'
# We specify CORS settings only if there is a public facing url - otherwise
# we are probably in local development and the only url in use is localhost
environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
@ -301,6 +299,27 @@ class RemoteSandboxService(SandboxService):
return None
return await self._to_sandbox_info(stored_sandbox)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> Union[SandboxInfo, None]:
"""Get a single sandbox by session API key."""
# Get all stored sandboxes for the current user
stmt = await self._secure_select()
result = await self.db_session.execute(stmt)
stored_sandboxes = result.scalars().all()
# Check each sandbox's runtime data for matching session_api_key
for stored_sandbox in stored_sandboxes:
try:
runtime = await self._get_runtime(stored_sandbox.id)
if runtime and runtime.get('session_api_key') == session_api_key:
return await self._to_sandbox_info(stored_sandbox, runtime)
except Exception:
# Continue checking other sandboxes if one fails
continue
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox by creating a remote runtime."""
try:

View File

@ -25,6 +25,12 @@ class SandboxService(ABC):
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox. Return None if the sandbox was not found."""
@abstractmethod
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key. Return None if the sandbox was not found."""
async def batch_get_sandboxes(
self, sandbox_ids: list[str]
) -> list[SandboxInfo | None]:

View File

@ -9,7 +9,11 @@ from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext, UserContextInjector
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
ProviderType,
)
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
@ -44,6 +48,9 @@ class AuthUserContext(UserContext):
self._user_info = user_info
return user_info
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.user_auth.get_provider_tokens()
async def get_provider_handler(self):
provider_handler = self._provider_handler
if not provider_handler:

View File

@ -5,7 +5,7 @@ from fastapi import Request
from openhands.app_server.errors import OpenHandsError
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderType
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
@ -24,6 +24,9 @@ class SpecifyUserContext(UserContext):
async def get_authenticated_git_url(self, repository: str) -> str:
raise NotImplementedError()
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
raise NotImplementedError()
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
raise NotImplementedError()

View File

@ -4,7 +4,7 @@ from openhands.app_server.services.injector import Injector
from openhands.app_server.user.user_models import (
UserInfo,
)
from openhands.integrations.provider import ProviderType
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.sdk.utils.models import DiscriminatedUnionMixin
@ -26,6 +26,10 @@ class UserContext(ABC):
async def get_authenticated_git_url(self, repository: str) -> str:
"""Get the provider tokens for the user"""
@abstractmethod
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
"""Get the latest tokens for all provider types"""
@abstractmethod
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
"""Get the latest token for the provider type given"""

View File

@ -90,4 +90,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
]
model_list = clarifai_models + model_list
return list(sorted(set(model_list)))
return sorted(set(model_list))

View File

@ -28,6 +28,8 @@ class TestLiveStatusAppConversationService:
"""Set up test fixtures."""
# Create mock dependencies
self.mock_user_context = Mock(spec=UserContext)
self.mock_user_auth = Mock()
self.mock_user_context.user_auth = self.mock_user_auth
self.mock_jwt_service = Mock()
self.mock_sandbox_service = Mock()
self.mock_sandbox_spec_service = Mock()
@ -50,6 +52,7 @@ class TestLiveStatusAppConversationService:
sandbox_startup_poll_frequency=1,
httpx_client=self.mock_httpx_client,
web_url='https://test.example.com',
openhands_provider_base_url='https://provider.example.com',
access_token_hard_timeout=None,
app_mode='test',
keycloak_auth_cookie=None,
@ -64,6 +67,7 @@ class TestLiveStatusAppConversationService:
self.mock_user.confirmation_mode = False
self.mock_user.search_api_key = None # Default to None
self.mock_user.condenser_max_size = None # Default to None
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
# Mock sandbox
self.mock_sandbox = Mock(spec=SandboxInfo)
@ -71,67 +75,83 @@ class TestLiveStatusAppConversationService:
self.mock_sandbox.status = SandboxStatus.RUNNING
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_no_provider(self):
"""Test _setup_secrets_for_git_provider with no git provider."""
async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
"""Test _setup_secrets_for_git_providers with no provider tokens."""
# Arrange
base_secrets = {'existing': 'secret'}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
# Act
result = await self.service._setup_secrets_for_git_provider(
None, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert result == base_secrets
self.mock_user_context.get_secrets.assert_called_once()
self.mock_user_context.get_provider_tokens.assert_called_once()
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_with_web_url(self):
"""Test _setup_secrets_for_git_provider with web URL (creates access token)."""
async def test_setup_secrets_for_git_providers_with_web_url(self):
"""Test _setup_secrets_for_git_providers with web URL (creates access token)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' in result
assert 'GITLAB_TOKEN' in result
assert isinstance(result['GITHUB_TOKEN'], LookupSecret)
assert isinstance(result['GITLAB_TOKEN'], LookupSecret)
assert (
result['GITHUB_TOKEN'].url
== 'https://test.example.com/api/v1/webhooks/secrets'
)
assert result['GITHUB_TOKEN'].headers['X-Access-Token'] == 'test_access_token'
self.mock_jwt_service.create_jws_token.assert_called_once_with(
payload={
'user_id': self.mock_user.id,
'provider_type': git_provider.value,
},
expires_in=None,
)
# Should be called twice, once for each provider
assert self.mock_jwt_service.create_jws_token.call_count == 2
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_with_saas_mode(self):
"""Test _setup_secrets_for_git_provider with SaaS mode (includes keycloak cookie)."""
async def test_setup_secrets_for_git_providers_with_saas_mode(self):
"""Test _setup_secrets_for_git_providers with SaaS mode (includes keycloak cookie)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.app_mode = 'saas'
self.service.keycloak_auth_cookie = 'test_cookie'
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
git_provider = ProviderType.GITLAB
# Mock provider tokens
provider_tokens = {
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITLAB_TOKEN' in result
@ -141,40 +161,60 @@ class TestLiveStatusAppConversationService:
assert lookup_secret.headers['Cookie'] == 'keycloak_auth=test_cookie'
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_without_web_url(self):
"""Test _setup_secrets_for_git_provider without web URL (uses static token)."""
async def test_setup_secrets_for_git_providers_without_web_url(self):
"""Test _setup_secrets_for_git_providers without web URL (uses static token)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.web_url = None
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_latest_token.return_value = 'static_token_value'
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' in result
assert isinstance(result['GITHUB_TOKEN'], StaticSecret)
assert result['GITHUB_TOKEN'].value.get_secret_value() == 'static_token_value'
self.mock_user_context.get_latest_token.assert_called_once_with(git_provider)
self.mock_user_context.get_latest_token.assert_called_once_with(
ProviderType.GITHUB
)
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_no_static_token(self):
"""Test _setup_secrets_for_git_provider when no static token is available."""
async def test_setup_secrets_for_git_providers_no_static_token(self):
"""Test _setup_secrets_for_git_providers when no static token is available."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.web_url = None
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_latest_token.return_value = None
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' not in result
@ -203,6 +243,70 @@ class TestLiveStatusAppConversationService:
assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp'
assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self):
"""openhands/* model uses user.llm_base_url when provided."""
# Arrange
self.mock_user.llm_model = 'openhands/special'
self.mock_user.llm_base_url = 'https://user-llm.example.com'
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self):
"""openhands/* model falls back to configured provider base URL."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url == 'https://provider.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self):
"""openhands/* model sets base_url to None when no sources available."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
self.service.openhands_provider_base_url = None
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url is None
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self):
"""Non-openhands model ignores provider base URL and uses user base URL."""
# Arrange
self.mock_user.llm_model = 'gpt-4'
self.mock_user.llm_base_url = 'https://user-llm.example.com'
self.service.openhands_provider_base_url = 'https://provider.example.com'
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_with_user_default_model(self):
"""Test _configure_llm_and_mcp using user's default model."""
@ -677,7 +781,7 @@ class TestLiveStatusAppConversationService:
mock_agent = Mock(spec=Agent)
mock_final_request = Mock(spec=StartConversationRequest)
self.service._setup_secrets_for_git_provider = AsyncMock(
self.service._setup_secrets_for_git_providers = AsyncMock(
return_value=mock_secrets
)
self.service._configure_llm_and_mcp = AsyncMock(
@ -705,8 +809,8 @@ class TestLiveStatusAppConversationService:
# Assert
assert result == mock_final_request
self.service._setup_secrets_for_git_provider.assert_called_once_with(
ProviderType.GITHUB, self.mock_user
self.service._setup_secrets_for_git_providers.assert_called_once_with(
self.mock_user
)
self.service._configure_llm_and_mcp.assert_called_once_with(
self.mock_user, 'gpt-4'

View File

@ -291,9 +291,7 @@ class TestEnvironmentInitialization:
)
# Verify
expected_webhook_url = (
'https://web.example.com/api/v1/webhooks/test-sandbox-123'
)
expected_webhook_url = 'https://web.example.com/api/v1/webhooks'
assert environment['EXISTING_VAR'] == 'existing_value'
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'

View File

@ -27,6 +27,7 @@ class MockSandboxService(SandboxService):
def __init__(self):
self.search_sandboxes_mock = AsyncMock()
self.get_sandbox_mock = AsyncMock()
self.get_sandbox_by_session_api_key_mock = AsyncMock()
self.start_sandbox_mock = AsyncMock()
self.resume_sandbox_mock = AsyncMock()
self.pause_sandbox_mock = AsyncMock()
@ -40,6 +41,11 @@ class MockSandboxService(SandboxService):
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
return await self.get_sandbox_mock(sandbox_id)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
return await self.get_sandbox_by_session_api_key_mock(session_api_key)
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
return await self.start_sandbox_mock(sandbox_spec_id)

View File

@ -188,6 +188,7 @@ class TestExperimentManagerIntegration:
sandbox_startup_poll_frequency=1,
httpx_client=httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@ -203,7 +204,7 @@ class TestExperimentManagerIntegration:
with (
patch.object(
service,
'_setup_secrets_for_git_provider',
'_setup_secrets_for_git_providers',
return_value={},
),
patch.object(

View File

@ -2166,6 +2166,7 @@ async def test_delete_v1_conversation_with_sub_conversations():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@ -2287,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@ -2438,6 +2440,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)