From ab098c9dfc0ff22c0efc7fbc0a2a2209a2b09f14 Mon Sep 17 00:00:00 2001 From: Pierrick Hymbert Date: Sat, 20 Sep 2025 20:28:53 +0200 Subject: [PATCH] fix(ssl): ensure default ssl context is used and allow to disable insecure verify --- config.template.toml | 3 + docs/usage/environment-variables.mdx | 3 +- enterprise/integrations/jira/jira_manager.py | 5 +- .../integrations/jira_dc/jira_dc_manager.py | 5 +- .../integrations/linear/linear_manager.py | 3 +- enterprise/server/auth/token_manager.py | 9 +-- enterprise/server/routes/api_keys.py | 3 + enterprise/server/routes/billing.py | 5 +- enterprise/server/routes/github_proxy.py | 5 +- .../saas_nested_conversation_manager.py | 5 ++ enterprise/storage/saas_settings_store.py | 2 + openhands/core/config/openhands_config.py | 6 ++ openhands/core/config/utils.py | 6 ++ .../integrations/bitbucket/service/base.py | 3 +- openhands/integrations/github/service/base.py | 5 +- openhands/integrations/gitlab/service/base.py | 5 +- openhands/integrations/provider.py | 3 +- openhands/resolver/interfaces/bitbucket.py | 3 +- openhands/runtime/impl/local/local_runtime.py | 3 +- .../docker_nested_conversation_manager.py | 5 ++ openhands/storage/__init__.py | 6 +- openhands/storage/batched_web_hook.py | 3 +- openhands/storage/web_hook.py | 3 +- openhands/utils/http_session.py | 66 ++++++++++++++++++- 24 files changed, 137 insertions(+), 28 deletions(-) diff --git a/config.template.toml b/config.template.toml index 542a3c2e71a4..dbea4891e48d 100644 --- a/config.template.toml +++ b/config.template.toml @@ -49,6 +49,9 @@ # Enable the browser environment #enable_browser = true +# Skip TLS certificate verification for outbound HTTP requests (NOT recommended) +#insecure_skip_verify = false + # Maximum budget per task, 0.0 means no limit #max_budget_per_task = 0.0 diff --git a/docs/usage/environment-variables.mdx b/docs/usage/environment-variables.mdx index 24d45548614f..9a2a1414a072 100644 --- a/docs/usage/environment-variables.mdx +++ b/docs/usage/environment-variables.mdx @@ -27,6 +27,7 @@ These variables correspond to the `[core]` section in `config.toml`: | `SAVE_TRAJECTORY_PATH` | string | `"./trajectories"` | Path to store conversation trajectories | | `REPLAY_TRAJECTORY_PATH` | string | `""` | Path to load and replay a trajectory file | | `FILE_STORE_PATH` | string | `"/tmp/file_store"` | File store directory path | +| `INSECURE_SKIP_VERIFY` | boolean | `false` | Skip TLS certificate verification for outbound HTTP requests (NOT recommended) | | `FILE_STORE` | string | `"memory"` | File store type (`memory`, `local`, etc.) | | `FILE_UPLOADS_MAX_FILE_SIZE_MB` | integer | `0` | Maximum file upload size in MB (0 = no limit) | | `FILE_UPLOADS_RESTRICT_FILE_TYPES` | boolean | `false` | Whether to restrict file upload types | @@ -248,4 +249,4 @@ export DEBUG_RUNTIME=true docker run -e LLM_API_KEY="your-key" -e DEBUG=true openhands/openhands ``` -6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults. \ No newline at end of file +6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults. diff --git a/enterprise/integrations/jira/jira_manager.py b/enterprise/integrations/jira/jira_manager.py index 7b0a335bcb60..b8c7fecfc91c 100644 --- a/enterprise/integrations/jira/jira_manager.py +++ b/enterprise/integrations/jira/jira_manager.py @@ -32,6 +32,7 @@ from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira' @@ -408,7 +409,7 @@ async def get_issue_details( svc_acc_api_key: str, ) -> Tuple[str, str]: url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key)) response.raise_for_status() issue_payload = response.json() @@ -443,7 +444,7 @@ async def send_message( f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment' ) data = {'body': message.message} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post( url, auth=(svc_acc_email, svc_acc_api_key), json=data ) diff --git a/enterprise/integrations/jira_dc/jira_dc_manager.py b/enterprise/integrations/jira_dc/jira_dc_manager.py index 0267ec4e7131..700267511bf9 100644 --- a/enterprise/integrations/jira_dc/jira_dc_manager.py +++ b/enterprise/integrations/jira_dc/jira_dc_manager.py @@ -34,6 +34,7 @@ from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option class JiraDcManager(Manager): @@ -422,7 +423,7 @@ async def get_issue_details( """Get issue details from Jira DC API.""" url = f'{job_context.base_api_url}/rest/api/2/issue/{job_context.issue_key}' headers = {'Authorization': f'Bearer {svc_acc_api_key}'} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, headers=headers) response.raise_for_status() issue_payload = response.json() @@ -452,7 +453,7 @@ async def send_message( url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment' headers = {'Authorization': f'Bearer {svc_acc_api_key}'} data = {'body': message.message} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, headers=headers, json=data) response.raise_for_status() return response.json() diff --git a/enterprise/integrations/linear/linear_manager.py b/enterprise/integrations/linear/linear_manager.py index 7a1b3933ace2..5eed24d674b8 100644 --- a/enterprise/integrations/linear/linear_manager.py +++ b/enterprise/integrations/linear/linear_manager.py @@ -31,6 +31,7 @@ from openhands.server.shared import server_config from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.server.user_auth.user_auth import UserAuth +from openhands.utils.http_session import httpx_verify_option class LinearManager(Manager): @@ -408,7 +409,7 @@ async def start_job(self, linear_view: LinearViewInterface): async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict: """Query Linear GraphQL API.""" headers = {'Authorization': api_key} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post( self.api_url, headers=headers, diff --git a/enterprise/server/auth/token_manager.py b/enterprise/server/auth/token_manager.py index 8189375c798b..52aadf676b79 100644 --- a/enterprise/server/auth/token_manager.py +++ b/enterprise/server/auth/token_manager.py @@ -37,6 +37,7 @@ from openhands.core.config import load_openhands_config from openhands.integrations.service_types import ProviderType +from openhands.utils.http_session import httpx_verify_option # Create a function to get config to avoid circular imports _config = None @@ -201,7 +202,7 @@ async def get_idp_tokens_from_keycloak( access_token: str, idp: ProviderType, ) -> dict[str, str | int]: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token' headers = { @@ -359,7 +360,7 @@ async def _refresh_github_token(self, refresh_token: str) -> dict[str, str | int 'refresh_token': refresh_token, 'grant_type': 'refresh_token', } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=payload) response.raise_for_status() logger.info('Successfully refreshed GitHub token') @@ -385,7 +386,7 @@ async def _refresh_gitlab_token(self, refresh_token: str) -> dict[str, str | int 'refresh_token': refresh_token, 'grant_type': 'refresh_token', } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=payload) response.raise_for_status() logger.info('Successfully refreshed GitLab token') @@ -413,7 +414,7 @@ async def _refresh_bitbucket_token( 'refresh_token': refresh_token, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, data=data, headers=headers) response.raise_for_status() logger.info('Successfully refreshed Bitbucket token') diff --git a/enterprise/server/routes/api_keys.py b/enterprise/server/routes/api_keys.py index 95ea8e4ec627..63dd8bec52a8 100644 --- a/enterprise/server/routes/api_keys.py +++ b/enterprise/server/routes/api_keys.py @@ -11,6 +11,7 @@ from openhands.core.logger import openhands_logger as logger from openhands.server.user_auth import get_user_id from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option # Helper functions for BYOR API key management @@ -67,6 +68,7 @@ async def generate_byor_key(user_id: str) -> str | None: try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, } @@ -119,6 +121,7 @@ async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool: try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, } diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index 1d52b54ffb29..f8edea28ee85 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -26,6 +26,7 @@ from storage.user_settings import UserSettings from openhands.server.user_auth import get_user_id +from openhands.utils.http_session import httpx_verify_option stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -78,7 +79,7 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float: async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse: if not stripe_service.STRIPE_API_KEY: return GetCreditsResponse() - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: user_json = await _get_litellm_user(client, user_id) credits = calculate_credits(user_json['user_info']) return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits))) @@ -390,7 +391,7 @@ async def success_callback(session_id: str, request: Request): ) raise HTTPException(status.HTTP_400_BAD_REQUEST) - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: # Update max budget in litellm user_json = await _get_litellm_user(client, billing_session.user_id) amount_subtotal = stripe_session.amount_subtotal or 0 diff --git a/enterprise/server/routes/github_proxy.py b/enterprise/server/routes/github_proxy.py index 14ba0bb8ceb5..d7f4452aa513 100644 --- a/enterprise/server/routes/github_proxy.py +++ b/enterprise/server/routes/github_proxy.py @@ -11,6 +11,7 @@ from server.logger import logger from openhands.server.shared import config +from openhands.utils.http_session import httpx_verify_option GITHUB_PROXY_ENDPOINTS = bool(os.environ.get('GITHUB_PROXY_ENDPOINTS')) @@ -87,7 +88,7 @@ async def access_token(request: Request, subdomain: str): ] body = urlencode(query_params, doseq=True) url = 'https://github.com/login/oauth/access_token' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, content=body) return Response( response.content, @@ -101,7 +102,7 @@ async def post_proxy(request: Request, subdomain: str, path: str): logger.info(f'github_proxy_post:1:{path}') body = await request.body() url = f'https://github.com/{path}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.post(url, content=body, headers=request.headers) return Response( response.content, diff --git a/enterprise/server/saas_nested_conversation_manager.py b/enterprise/server/saas_nested_conversation_manager.py index 1f1af3045b41..a15ec14c5f89 100644 --- a/enterprise/server/saas_nested_conversation_manager.py +++ b/enterprise/server/saas_nested_conversation_manager.py @@ -55,6 +55,7 @@ from openhands.utils.import_utils import get_impl from openhands.utils.shutdown_listener import should_continue from openhands.utils.utils import create_registry_and_conversation_stats +from openhands.utils.http_session import httpx_verify_option # Pattern for accessing runtime pods externally RUNTIME_URL_PATTERN = os.getenv( @@ -261,6 +262,7 @@ async def _start_conversation( ): logger.info('starting_nested_conversation', extra={'sid': sid}) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': session_api_key, } @@ -449,6 +451,7 @@ async def send_event_to_conversation(self, sid: str, data: dict): raise ValueError(f'no_such_conversation:{sid}') nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': runtime['session_api_key'], } @@ -516,6 +519,7 @@ async def _get_runtime_status_from_nested_runtime( return None async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': session_api_key, } @@ -792,6 +796,7 @@ async def _create_runtime( @contextlib.asynccontextmanager async def _httpx_client(self): async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={'X-API-Key': self.config.sandbox.api_key or ''}, timeout=_HTTP_TIMEOUT, ) as client: diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index 3614d99d4913..7b3856e97121 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -32,6 +32,7 @@ from openhands.storage import get_file_store from openhands.storage.settings.settings_store import SettingsStore from openhands.utils.async_utils import call_sync_from_async +from openhands.utils.http_session import httpx_verify_option @dataclass @@ -216,6 +217,7 @@ async def update_settings_with_litellm_default( ) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'x-goog-api-key': LITE_LLM_API_KEY, } diff --git a/openhands/core/config/openhands_config.py b/openhands/core/config/openhands_config.py index 24990bc7704b..abf282ce7bfa 100644 --- a/openhands/core/config/openhands_config.py +++ b/openhands/core/config/openhands_config.py @@ -37,6 +37,8 @@ class OpenHandsConfig(BaseModel): file_store_web_hook_url: Optional url for file store web hook file_store_web_hook_headers: Optional headers for file_store web hook enable_browser: Whether to enable the browser environment + insecure_skip_verify: When True, TLS certificate verification is disabled for outbound HTTP requests. + Defaults to None, meaning the system default (verify certificates). save_trajectory_path: Either a folder path to store trajectories with auto-generated filenames, or a designated trajectory file path. save_screenshots_in_trajectory: Whether to save screenshots in trajectory (in encoded image format). replay_trajectory_path: Path to load trajectory and replay. If provided, trajectory would be replayed first before user's instruction. @@ -74,6 +76,10 @@ class OpenHandsConfig(BaseModel): file_store_web_hook_url: str | None = Field(default=None) file_store_web_hook_headers: dict | None = Field(default=None) file_store_web_hook_batch: bool = Field(default=False) + insecure_skip_verify: bool | None = Field( + default=None, + description='Disable TLS certificate verification when set to true. Defaults to verifying certificates.', + ) enable_browser: bool = Field(default=True) save_trajectory_path: str | None = Field(default=None) save_screenshots_in_trajectory: bool = Field(default=False) diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 59ded7d598a5..b24baa4f5a45 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -32,6 +32,7 @@ from openhands.storage import get_file_store from openhands.storage.files import FileStore from openhands.utils.import_utils import get_impl +from openhands.utils.http_session import configure_http_session JWT_SECRET = '.jwt_secret' load_dotenv() @@ -456,6 +457,11 @@ def finalize_config(cfg: OpenHandsConfig) -> None: ) ) + if cfg.insecure_skip_verify is None: + configure_http_session() + else: + configure_http_session(verify=not cfg.insecure_skip_verify) + # If CLIRuntime is selected, disable Jupyter for all agents # Assuming 'cli' is the identifier for CLIRuntime if cfg.runtime and cfg.runtime.lower() == 'cli': diff --git a/openhands/integrations/bitbucket/service/base.py b/openhands/integrations/bitbucket/service/base.py index d7c9b4adf786..78c27cf06124 100644 --- a/openhands/integrations/bitbucket/service/base.py +++ b/openhands/integrations/bitbucket/service/base.py @@ -14,6 +14,7 @@ ResourceNotFoundError, User, ) +from openhands.utils.http_session import httpx_verify_option class BitBucketMixinBase(BaseGitService, HTTPClient): @@ -83,7 +84,7 @@ async def _make_request( """ try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: bitbucket_headers = await self._get_headers() response = await self.execute_request( client, url, bitbucket_headers, params, method diff --git a/openhands/integrations/github/service/base.py b/openhands/integrations/github/service/base.py index 556c64739087..7646249fbe7b 100644 --- a/openhands/integrations/github/service/base.py +++ b/openhands/integrations/github/service/base.py @@ -11,6 +11,7 @@ UnknownException, User, ) +from openhands.utils.http_session import httpx_verify_option class GitHubMixinBase(BaseGitService, HTTPClient): @@ -43,7 +44,7 @@ async def _make_request( method: RequestMethod = RequestMethod.GET, ) -> tuple[Any, dict]: # type: ignore[override] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: github_headers = await self._get_headers() # Make initial request @@ -83,7 +84,7 @@ async def execute_graphql_query( self, query: str, variables: dict[str, Any] ) -> dict[str, Any]: try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: github_headers = await self._get_headers() response = await client.post( diff --git a/openhands/integrations/gitlab/service/base.py b/openhands/integrations/gitlab/service/base.py index 239d97272018..fca117194266 100644 --- a/openhands/integrations/gitlab/service/base.py +++ b/openhands/integrations/gitlab/service/base.py @@ -10,6 +10,7 @@ UnknownException, User, ) +from openhands.utils.http_session import httpx_verify_option class GitLabMixinBase(BaseGitService, HTTPClient): @@ -41,7 +42,7 @@ async def _make_request( method: RequestMethod = RequestMethod.GET, ) -> tuple[Any, dict]: # type: ignore[override] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: gitlab_headers = await self._get_headers() # Make initial request @@ -99,7 +100,7 @@ async def execute_graphql_query( if variables is None: variables = {} try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: gitlab_headers = await self._get_headers() # Add content type header for GraphQL gitlab_headers['Content-Type'] = 'application/json' diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index f4a26d8a3f82..77645ab17b19 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -36,6 +36,7 @@ ) from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse from openhands.server.types import AppMode +from openhands.utils.http_session import httpx_verify_option class ProviderToken(BaseModel): @@ -174,7 +175,7 @@ async def _get_latest_provider_token( ) -> SecretStr | None: """Get latest token from service""" try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: resp = await client.get( self.REFRESH_TOKEN_URL, headers={ diff --git a/openhands/resolver/interfaces/bitbucket.py b/openhands/resolver/interfaces/bitbucket.py index 4baaed7750f1..3799f7f6bca7 100644 --- a/openhands/resolver/interfaces/bitbucket.py +++ b/openhands/resolver/interfaces/bitbucket.py @@ -10,6 +10,7 @@ ReviewThread, ) from openhands.resolver.utils import extract_issue_references +from openhands.utils.http_session import httpx_verify_option class BitbucketIssueHandler(IssueHandlerInterface): @@ -91,7 +92,7 @@ async def get_issue(self, issue_number: int) -> Issue: An Issue object """ url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}' - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: response = await client.get(url, headers=self.headers) response.raise_for_status() data = response.json() diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index ee9fc7a706d4..c2af037289f1 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -38,6 +38,7 @@ ) from openhands.runtime.plugins import PluginRequirement from openhands.runtime.plugins.vscode import VSCodeRequirement +from openhands.utils.http_session import httpx_verify_option from openhands.runtime.runtime_status import RuntimeStatus from openhands.runtime.utils import find_available_tcp_port from openhands.runtime.utils.command import get_action_execution_server_startup_command @@ -760,7 +761,7 @@ def _create_warm_server( ) # Wait for the server to be ready - session = httpx.Client(timeout=30) + session = httpx.Client(timeout=30, verify=httpx_verify_option()) # Use tenacity to retry the connection @tenacity.retry( diff --git a/openhands/server/conversation_manager/docker_nested_conversation_manager.py b/openhands/server/conversation_manager/docker_nested_conversation_manager.py index 13f23e01ab9c..d6c3954cc3c5 100644 --- a/openhands/server/conversation_manager/docker_nested_conversation_manager.py +++ b/openhands/server/conversation_manager/docker_nested_conversation_manager.py @@ -44,6 +44,7 @@ from openhands.utils.async_utils import call_sync_from_async from openhands.utils.import_utils import get_impl from openhands.utils.utils import create_registry_and_conversation_stats +from openhands.utils.http_session import httpx_verify_option @dataclass @@ -199,6 +200,7 @@ async def _start_conversation( await call_sync_from_async(runtime.wait_until_alive) await call_sync_from_async(runtime.setup_initial_env) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) } @@ -295,6 +297,7 @@ async def request_llm_completion( async def send_event_to_conversation(self, sid, data): async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) } @@ -318,6 +321,7 @@ async def close_session(self, sid: str): try: nested_url = self.get_nested_url_for_container(container) async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation(sid) } @@ -356,6 +360,7 @@ async def _get_runtime_status_from_nested_runtime( """ try: async with httpx.AsyncClient( + verify=httpx_verify_option(), headers={ 'X-Session-API-Key': self._get_session_api_key_for_conversation( conversation_id diff --git a/openhands/storage/__init__.py b/openhands/storage/__init__.py index 5d8a744b2439..8ac6f47a9ee9 100644 --- a/openhands/storage/__init__.py +++ b/openhands/storage/__init__.py @@ -9,6 +9,7 @@ from openhands.storage.memory import InMemoryFileStore from openhands.storage.s3 import S3FileStore from openhands.storage.web_hook import WebHookFileStore +from openhands.utils.http_session import httpx_verify_option def get_file_store( @@ -38,7 +39,10 @@ def get_file_store( 'SESSION_API_KEY' ) - client = httpx.Client(headers=file_store_web_hook_headers or {}) + client = httpx.Client( + headers=file_store_web_hook_headers or {}, + verify=httpx_verify_option(), + ) if file_store_web_hook_batch: # Use batched webhook file store diff --git a/openhands/storage/batched_web_hook.py b/openhands/storage/batched_web_hook.py index 6a9495d5e07f..7cd6d17fc49a 100644 --- a/openhands/storage/batched_web_hook.py +++ b/openhands/storage/batched_web_hook.py @@ -7,6 +7,7 @@ from openhands.core.logger import openhands_logger as logger from openhands.storage.files import FileStore from openhands.utils.async_utils import EXECUTOR +from openhands.utils.http_session import httpx_verify_option # Constants for batching configuration WEBHOOK_BATCH_TIMEOUT_SECONDS = 5.0 @@ -65,7 +66,7 @@ def __init__( self.file_store = file_store self.base_url = base_url if client is None: - client = httpx.Client() + client = httpx.Client(verify=httpx_verify_option()) self.client = client # Use provided values or default constants diff --git a/openhands/storage/web_hook.py b/openhands/storage/web_hook.py index 71f7c73eddb1..d41ef0b93b84 100644 --- a/openhands/storage/web_hook.py +++ b/openhands/storage/web_hook.py @@ -3,6 +3,7 @@ from openhands.storage.files import FileStore from openhands.utils.async_utils import EXECUTOR +from openhands.utils.http_session import httpx_verify_option class WebHookFileStore(FileStore): @@ -34,7 +35,7 @@ def __init__( self.file_store = file_store self.base_url = base_url if client is None: - client = httpx.Client() + client = httpx.Client(verify=httpx_verify_option()) self.client = client def write(self, path: str, contents: str | bytes) -> None: diff --git a/openhands/utils/http_session.py b/openhands/utils/http_session.py index 2244e45fb088..d5478d4900fb 100644 --- a/openhands/utils/http_session.py +++ b/openhands/utils/http_session.py @@ -1,11 +1,71 @@ +import os +import ssl from dataclasses import dataclass, field +from threading import Lock from typing import MutableMapping import httpx from openhands.core.logger import openhands_logger as logger -CLIENT = httpx.Client() + +def _env_insecure_skip_verify() -> bool: + truthy = {'1', 'true', 'yes', 'on'} + for env_var in ('OPENHANDS_INSECURE_SKIP_VERIFY', 'INSECURE_SKIP_VERIFY'): + value = os.environ.get(env_var) + if value is not None: + return value.strip().lower() in truthy + return False + + +_client_lock = Lock() +_verify_certificates: bool = not _env_insecure_skip_verify() +_client: httpx.Client | None = None + + +def httpx_verify_option() -> ssl.SSLContext | bool: + """Return the verify option to pass when creating an HTTPX client.""" + + if _env_insecure_skip_verify(): + return False + return ssl.create_default_context() + + +def _build_client(verify: bool) -> httpx.Client: + if verify: + return httpx.Client(verify=ssl.create_default_context()) + return httpx.Client(verify=False) + + +def _get_client() -> httpx.Client: + global _client + if _client is None: + with _client_lock: + if _client is None: + _client = _build_client(_verify_certificates) + return _client + + +def configure_http_session(*, verify: bool | None = None) -> None: + """Configure the shared HTTPX client used by HttpSession.""" + + global _client, _verify_certificates + + target_verify = _verify_certificates + if verify is not None: + target_verify = verify + elif _client is None: + # Ensure we honour environment variables on first configuration + target_verify = not _env_insecure_skip_verify() + + if target_verify == _verify_certificates and _client is not None: + return + + with _client_lock: + if _client is not None: + _client.close() + _verify_certificates = target_verify + _client = _build_client(_verify_certificates) @dataclass @@ -27,7 +87,7 @@ def request(self, *args, **kwargs): headers = kwargs.get('headers') or {} headers = {**self.headers, **headers} kwargs['headers'] = headers - return CLIENT.request(*args, **kwargs) + return _get_client().request(*args, **kwargs) def stream(self, *args, **kwargs): if self._is_closed: @@ -38,7 +98,7 @@ def stream(self, *args, **kwargs): headers = kwargs.get('headers') or {} headers = {**self.headers, **headers} kwargs['headers'] = headers - return CLIENT.stream(*args, **kwargs) + return _get_client().stream(*args, **kwargs) def get(self, *args, **kwargs): return self.request('GET', *args, **kwargs)