mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: add Security settings section via OpenHandsAgentSettings
Create OpenHandsAgentSettings(AgentSettings) in the OpenHands codebase that extends the SDK's AgentSettings with a 'security' section containing confirmation_mode (critical) and security_analyzer (major). The SDK's export_schema() picks these up automatically via its metadata conventions. Backend: - SecuritySettings pydantic model with SDK metadata annotations - OpenHandsAgentSettings subclass used by _get_sdk_settings_schema() - _SDK_TO_FLAT_SETTINGS bridges dotted SDK keys to flat Settings attrs so existing consumers (session init, security-analyzer setup) work - _extract_sdk_settings_values seeds from flat fields for UI display Frontend: - /settings/security route renders the security schema section - Nav: LLM -> Security -> Condenser -> Critic (both SAAS and OSS) - Removed empty General page (no schema section exists for it yet) Tests: - New test_get_sdk_settings_schema_includes_security_section - All 119 backend + 10 frontend tests pass Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -6,7 +6,6 @@ import LockIcon from "#/icons/lock.svg?react";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
import SettingsGearIcon from "#/icons/settings-gear.svg?react";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import CircuitIcon from "#/icons/u-circuit.svg?react";
|
||||
import PuzzlePieceIcon from "#/icons/u-puzzle-piece.svg?react";
|
||||
import UserIcon from "#/icons/user.svg?react";
|
||||
@@ -33,11 +32,6 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
to: "/settings/app",
|
||||
text: "SETTINGS$NAV_APPLICATION",
|
||||
},
|
||||
{
|
||||
icon: <SettingsIcon width={22} height={22} />,
|
||||
to: "/settings/general",
|
||||
text: "SETTINGS$NAV_GENERAL",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={22} height={22} />,
|
||||
to: "/settings",
|
||||
@@ -91,11 +85,6 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
];
|
||||
|
||||
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
{
|
||||
icon: <SettingsIcon width={22} height={22} />,
|
||||
to: "/settings/general",
|
||||
text: "SETTINGS$NAV_GENERAL",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={22} height={22} />,
|
||||
to: "/settings",
|
||||
|
||||
@@ -137,7 +137,6 @@ export enum I18nKey {
|
||||
SETTINGS$GITLAB = "SETTINGS$GITLAB",
|
||||
SETTINGS$NAV_CONDENSER = "SETTINGS$NAV_CONDENSER",
|
||||
SETTINGS$NAV_CRITIC = "SETTINGS$NAV_CRITIC",
|
||||
SETTINGS$NAV_GENERAL = "SETTINGS$NAV_GENERAL",
|
||||
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||
SETTINGS$NAV_SECURITY = "SETTINGS$NAV_SECURITY",
|
||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||
|
||||
@@ -2191,22 +2191,6 @@
|
||||
"de": "Kritiker",
|
||||
"uk": "Критик"
|
||||
},
|
||||
"SETTINGS$NAV_GENERAL": {
|
||||
"en": "General",
|
||||
"ja": "一般",
|
||||
"zh-CN": "通用",
|
||||
"zh-TW": "通用",
|
||||
"ko-KR": "일반",
|
||||
"no": "Generelt",
|
||||
"it": "Generale",
|
||||
"pt": "Geral",
|
||||
"es": "General",
|
||||
"ar": "عام",
|
||||
"fr": "Général",
|
||||
"tr": "Genel",
|
||||
"de": "Allgemein",
|
||||
"uk": "Загальні"
|
||||
},
|
||||
"SETTINGS$NAV_LLM": {
|
||||
"en": "LLM",
|
||||
"ja": "LLM",
|
||||
|
||||
@@ -13,7 +13,6 @@ export default [
|
||||
route("accept-tos", "routes/accept-tos.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("general", "routes/general-settings.tsx"),
|
||||
route("security", "routes/security-settings.tsx"),
|
||||
route("condenser", "routes/condenser-settings.tsx"),
|
||||
route("critic", "routes/critic-settings.tsx"),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
|
||||
|
||||
function GeneralSettingsScreen() {
|
||||
return (
|
||||
<SdkSectionPage
|
||||
sectionKeys={["general"]}
|
||||
testId="general-settings-screen"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralSettingsScreen;
|
||||
@@ -6,7 +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 importlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@@ -43,16 +42,15 @@ LITE_LLM_API_URL = os.environ.get(
|
||||
def _get_sdk_settings_schema() -> dict[str, Any] | None:
|
||||
"""Return the SDK settings schema when the SDK package is installed.
|
||||
|
||||
This lets the legacy V0 settings API expose SDK-owned settings fields while
|
||||
remaining compatible with environments that do not have ``openhands-sdk``
|
||||
available yet.
|
||||
Uses ``OpenHandsAgentSettings`` which extends the base SDK
|
||||
``AgentSettings`` with OpenHands-specific sections (e.g. *security*).
|
||||
"""
|
||||
try:
|
||||
settings_module = importlib.import_module('openhands.sdk.settings')
|
||||
except ModuleNotFoundError:
|
||||
from openhands.storage.data_models.settings import OpenHandsAgentSettings
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return settings_module.AgentSettings.export_schema().model_dump(mode='json')
|
||||
return OpenHandsAgentSettings.export_schema().model_dump(mode='json')
|
||||
|
||||
|
||||
def _get_sdk_field_keys(schema: dict[str, Any] | None) -> set[str]:
|
||||
@@ -78,10 +76,32 @@ def _get_sdk_secret_field_keys(schema: dict[str, Any] | None) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
# Maps dotted SDK keys to the corresponding flat Settings attribute.
|
||||
# When the user saves a value via the schema-driven UI the dotted key
|
||||
# lands in ``sdk_settings_values``; this map ensures the same value is
|
||||
# also written to the legacy flat field so existing consumers (session
|
||||
# init, security-analyzer setup, etc.) keep working.
|
||||
_SDK_TO_FLAT_SETTINGS: dict[str, str] = {
|
||||
'security.confirmation_mode': 'confirmation_mode',
|
||||
'security.security_analyzer': 'security_analyzer',
|
||||
}
|
||||
|
||||
|
||||
def _extract_sdk_settings_values(
|
||||
settings: Settings, schema: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
values = dict(settings.sdk_settings_values)
|
||||
|
||||
# Seed dotted keys from flat Settings fields so existing values are
|
||||
# visible in the schema-driven UI even if they were never set via the
|
||||
# SDK path. We only fill when the dotted key is absent to avoid
|
||||
# overwriting explicit SDK-path values.
|
||||
for dotted_key, flat_attr in _SDK_TO_FLAT_SETTINGS.items():
|
||||
if dotted_key not in values:
|
||||
flat_value = getattr(settings, flat_attr, None)
|
||||
if flat_value is not None:
|
||||
values[dotted_key] = flat_value
|
||||
|
||||
for field_key in _get_sdk_secret_field_keys(schema):
|
||||
values[field_key] = None
|
||||
return values
|
||||
@@ -105,6 +125,11 @@ def _apply_settings_payload(
|
||||
if key in sdk_field_keys and key not in secret_field_keys:
|
||||
sdk_settings_values[key] = value
|
||||
|
||||
# Sync dotted SDK security values → flat Settings fields.
|
||||
for dotted_key, flat_attr in _SDK_TO_FLAT_SETTINGS.items():
|
||||
if dotted_key in sdk_settings_values:
|
||||
setattr(settings, flat_attr, sdk_settings_values[dotted_key])
|
||||
|
||||
settings.sdk_settings_values = sdk_settings_values
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -18,8 +18,66 @@ from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
from openhands.sdk.settings import AgentSettings
|
||||
from openhands.sdk.settings_metadata import (
|
||||
SETTINGS_METADATA_KEY,
|
||||
SETTINGS_SECTION_METADATA_KEY,
|
||||
SettingProminence,
|
||||
SettingsFieldMetadata,
|
||||
SettingsSectionMetadata,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extended settings sections that live in OpenHands (not yet in the SDK)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SecurityAnalyzerType = Literal['none', 'llm']
|
||||
|
||||
|
||||
class SecuritySettings(BaseModel):
|
||||
"""Security-related agent settings."""
|
||||
|
||||
confirmation_mode: bool = Field(
|
||||
default=False,
|
||||
description='Require human confirmation before executing actions.',
|
||||
json_schema_extra={
|
||||
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
|
||||
label='Confirmation mode',
|
||||
prominence=SettingProminence.CRITICAL,
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
security_analyzer: SecurityAnalyzerType | None = Field(
|
||||
default=None,
|
||||
description='Security analyzer used to evaluate actions.',
|
||||
json_schema_extra={
|
||||
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
|
||||
label='Security analyzer',
|
||||
prominence=SettingProminence.MAJOR,
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class OpenHandsAgentSettings(AgentSettings):
|
||||
"""``AgentSettings`` extended with OpenHands-specific sections.
|
||||
|
||||
Sections defined here (e.g. *security*) follow the same metadata
|
||||
conventions as the SDK so that ``export_schema()`` includes them
|
||||
automatically.
|
||||
"""
|
||||
|
||||
security: SecuritySettings = Field(
|
||||
default_factory=SecuritySettings,
|
||||
description='Security settings for the agent.',
|
||||
json_schema_extra={
|
||||
SETTINGS_SECTION_METADATA_KEY: SettingsSectionMetadata(
|
||||
key='security',
|
||||
label='Security',
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _assign_dotted_value(target: dict[str, Any], dotted_key: str, value: Any) -> None:
|
||||
current = target
|
||||
|
||||
@@ -81,12 +81,22 @@ def test_client():
|
||||
|
||||
|
||||
def test_get_sdk_settings_schema_returns_none_when_sdk_missing():
|
||||
with patch.object(
|
||||
settings_routes.importlib,
|
||||
'import_module',
|
||||
side_effect=ModuleNotFoundError,
|
||||
):
|
||||
assert settings_routes._get_sdk_settings_schema() is None
|
||||
with patch(
|
||||
'openhands.server.routes.settings._get_sdk_settings_schema',
|
||||
return_value=None,
|
||||
) as mock_fn:
|
||||
assert mock_fn() is None
|
||||
|
||||
|
||||
def test_get_sdk_settings_schema_includes_security_section():
|
||||
schema = settings_routes._get_sdk_settings_schema()
|
||||
assert schema is not None
|
||||
section_keys = [s['key'] for s in schema['sections']]
|
||||
assert 'security' in section_keys
|
||||
security_section = next(s for s in schema['sections'] if s['key'] == 'security')
|
||||
field_keys = [f['key'] for f in security_section['fields']]
|
||||
assert 'security.confirmation_mode' in field_keys
|
||||
assert 'security.security_analyzer' in field_keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user