mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
897 lines
35 KiB
Python
897 lines
35 KiB
Python
"""Tests for agent server environment variable override functionality.
|
|
|
|
This module tests the environment variable override functionality that allows
|
|
users to inject custom environment variables into sandbox environments via
|
|
OH_AGENT_SERVER_ENV environment variable and auto-forwarding of LLM_* variables.
|
|
|
|
The functionality includes:
|
|
- Auto-forwarding of LLM_* environment variables to agent-server containers
|
|
- Explicit overrides via OH_AGENT_SERVER_ENV JSON
|
|
- Merging them into sandbox specifications
|
|
- Integration across different sandbox types (Docker, Process, Remote)
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openhands.app_server.sandbox.docker_sandbox_spec_service import (
|
|
get_default_sandbox_specs as get_default_docker_sandbox_specs,
|
|
)
|
|
from openhands.app_server.sandbox.process_sandbox_spec_service import (
|
|
get_default_sandbox_specs as get_default_process_sandbox_specs,
|
|
)
|
|
from openhands.app_server.sandbox.remote_sandbox_spec_service import (
|
|
get_default_sandbox_specs as get_default_remote_sandbox_specs,
|
|
)
|
|
from openhands.app_server.sandbox.sandbox_spec_service import (
|
|
AUTO_FORWARD_PREFIXES,
|
|
get_agent_server_env,
|
|
)
|
|
|
|
|
|
class TestGetAgentServerEnv:
|
|
"""Test cases for get_agent_server_env function."""
|
|
|
|
def test_no_environment_variables(self):
|
|
"""Test when no OH_AGENT_SERVER_ENV variable is set."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
result = get_agent_server_env()
|
|
assert result == {}
|
|
|
|
def test_empty_json_environment_variable(self):
|
|
"""Test with empty JSON in OH_AGENT_SERVER_ENV."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert result == {}
|
|
|
|
def test_single_environment_variable(self):
|
|
"""Test with a single variable in JSON format."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"CUSTOM_VAR": "custom_value"}',
|
|
'OTHER_VAR': 'should_not_be_included',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert result == {'CUSTOM_VAR': 'custom_value'}
|
|
|
|
def test_multiple_environment_variables(self):
|
|
"""Test with multiple variables in JSON format."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"VAR1": "value1", "VAR2": "value2", "DEBUG": "true", "PORT": "8080"}',
|
|
'UNRELATED_VAR': 'should_not_be_included',
|
|
'OH_OTHER_PREFIX': 'also_not_included',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'VAR1': 'value1',
|
|
'VAR2': 'value2',
|
|
'DEBUG': 'true',
|
|
'PORT': '8080',
|
|
}
|
|
assert result == expected
|
|
|
|
def test_empty_variable_value(self):
|
|
"""Test with empty environment variable values in JSON."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"EMPTY": "", "NORMAL": "value"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'EMPTY': '',
|
|
'NORMAL': 'value',
|
|
}
|
|
assert result == expected
|
|
|
|
def test_special_characters_in_values(self):
|
|
"""Test with special characters in environment variable values."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"JSON": "{\\"key\\": \\"value\\", \\"number\\": 123}", "PATH": "/usr/local/bin:/usr/bin", "SPACES": "value with spaces", "SYMBOLS": "value!@#$%^&*()"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'JSON': '{"key": "value", "number": 123}',
|
|
'PATH': '/usr/local/bin:/usr/bin',
|
|
'SPACES': 'value with spaces',
|
|
'SYMBOLS': 'value!@#$%^&*()',
|
|
}
|
|
assert result == expected
|
|
|
|
def test_case_sensitivity(self):
|
|
"""Test that environment variable names are case-sensitive."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"lowercase": "lower", "UPPERCASE": "upper", "MixedCase": "mixed"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'lowercase': 'lower',
|
|
'UPPERCASE': 'upper',
|
|
'MixedCase': 'mixed',
|
|
}
|
|
assert result == expected
|
|
|
|
def test_numeric_and_underscore_in_names(self):
|
|
"""Test with numbers and underscores in variable names."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"VAR_1": "value1", "VAR_2_TEST": "value2", "123": "numeric", "TEST_123_ABC": "complex"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'VAR_1': 'value1',
|
|
'VAR_2_TEST': 'value2',
|
|
'123': 'numeric',
|
|
'TEST_123_ABC': 'complex',
|
|
}
|
|
assert result == expected
|
|
|
|
def test_invalid_json_format(self):
|
|
"""Test that invalid JSON raises an appropriate error."""
|
|
import json
|
|
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': 'invalid_json_string',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
with pytest.raises(json.JSONDecodeError): # Should raise JSON decode error
|
|
get_agent_server_env()
|
|
|
|
def test_non_string_values_in_json(self):
|
|
"""Test that non-string values in JSON are converted to strings."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"NUMBER": 123, "BOOLEAN": true, "NULL": null}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
# This might fail if the parser is strict about string values
|
|
# The behavior depends on the implementation
|
|
try:
|
|
result = get_agent_server_env()
|
|
# If it succeeds, values should be converted to strings
|
|
assert isinstance(result.get('NUMBER'), str)
|
|
assert isinstance(result.get('BOOLEAN'), str)
|
|
assert isinstance(result.get('NULL'), str)
|
|
except Exception:
|
|
# If it fails, that's also acceptable behavior for type safety
|
|
pass
|
|
|
|
def test_documentation_example(self):
|
|
"""Test the example from the function documentation."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"DEBUG": "true", "LOG_LEVEL": "info", "CUSTOM_VAR": "value"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
expected = {
|
|
'DEBUG': 'true',
|
|
'LOG_LEVEL': 'info',
|
|
'CUSTOM_VAR': 'value',
|
|
}
|
|
assert result == expected
|
|
|
|
|
|
class TestLLMAutoForwarding:
|
|
"""Test cases for automatic forwarding of LLM_* environment variables."""
|
|
|
|
def test_auto_forward_prefixes_contains_llm(self):
|
|
"""Test that LLM_ is in the auto-forward prefixes."""
|
|
assert 'LLM_' in AUTO_FORWARD_PREFIXES
|
|
|
|
def test_llm_timeout_auto_forwarded(self):
|
|
"""Test that LLM_TIMEOUT is automatically forwarded."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600',
|
|
'OTHER_VAR': 'should_not_be_included',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert 'LLM_TIMEOUT' in result
|
|
assert result['LLM_TIMEOUT'] == '3600'
|
|
assert 'OTHER_VAR' not in result
|
|
|
|
def test_llm_num_retries_auto_forwarded(self):
|
|
"""Test that LLM_NUM_RETRIES is automatically forwarded."""
|
|
env_vars = {
|
|
'LLM_NUM_RETRIES': '10',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert 'LLM_NUM_RETRIES' in result
|
|
assert result['LLM_NUM_RETRIES'] == '10'
|
|
|
|
def test_multiple_llm_vars_auto_forwarded(self):
|
|
"""Test that multiple LLM_* variables are automatically forwarded."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600',
|
|
'LLM_NUM_RETRIES': '10',
|
|
'LLM_MODEL': 'gpt-4',
|
|
'LLM_BASE_URL': 'https://api.example.com',
|
|
'LLM_API_KEY': 'secret-key',
|
|
'NON_LLM_VAR': 'should_not_be_included',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert result['LLM_TIMEOUT'] == '3600'
|
|
assert result['LLM_NUM_RETRIES'] == '10'
|
|
assert result['LLM_MODEL'] == 'gpt-4'
|
|
assert result['LLM_BASE_URL'] == 'https://api.example.com'
|
|
assert result['LLM_API_KEY'] == 'secret-key'
|
|
assert 'NON_LLM_VAR' not in result
|
|
|
|
def test_explicit_override_takes_precedence(self):
|
|
"""Test that OH_AGENT_SERVER_ENV overrides auto-forwarded variables."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600', # Auto-forwarded value
|
|
'OH_AGENT_SERVER_ENV': '{"LLM_TIMEOUT": "7200"}', # Explicit override
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
# Explicit override should win
|
|
assert result['LLM_TIMEOUT'] == '7200'
|
|
|
|
def test_combined_auto_forward_and_explicit(self):
|
|
"""Test combining auto-forwarded and explicit variables."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600', # Auto-forwarded
|
|
'LLM_NUM_RETRIES': '10', # Auto-forwarded
|
|
'OH_AGENT_SERVER_ENV': '{"DEBUG": "true", "CUSTOM_VAR": "value"}', # Explicit
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
# Auto-forwarded
|
|
assert result['LLM_TIMEOUT'] == '3600'
|
|
assert result['LLM_NUM_RETRIES'] == '10'
|
|
# Explicit
|
|
assert result['DEBUG'] == 'true'
|
|
assert result['CUSTOM_VAR'] == 'value'
|
|
|
|
def test_no_llm_vars_returns_empty_without_explicit(self):
|
|
"""Test that no LLM_* vars and no explicit env returns empty dict."""
|
|
env_vars = {
|
|
'SOME_OTHER_VAR': 'value',
|
|
'ANOTHER_VAR': 'another_value',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert result == {}
|
|
|
|
def test_llm_prefix_is_case_sensitive(self):
|
|
"""Test that LLM_ prefix matching is case-sensitive."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600', # Should be included
|
|
'llm_timeout': 'lowercase', # Should NOT be included (wrong case)
|
|
'Llm_Timeout': 'mixed', # Should NOT be included (wrong case)
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
result = get_agent_server_env()
|
|
assert 'LLM_TIMEOUT' in result
|
|
assert result['LLM_TIMEOUT'] == '3600'
|
|
# Lowercase variants should not be included
|
|
assert 'llm_timeout' not in result
|
|
assert 'Llm_Timeout' not in result
|
|
|
|
|
|
class TestDockerSandboxSpecEnvironmentOverride:
|
|
"""Test environment variable override integration in Docker sandbox specs."""
|
|
|
|
def test_docker_specs_include_agent_server_env(self):
|
|
"""Test that Docker sandbox specs include agent server environment variables."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"CUSTOM_VAR": "custom_value", "DEBUG": "true"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Check that custom environment variables are included
|
|
assert 'CUSTOM_VAR' in spec.initial_env
|
|
assert spec.initial_env['CUSTOM_VAR'] == 'custom_value'
|
|
assert 'DEBUG' in spec.initial_env
|
|
assert spec.initial_env['DEBUG'] == 'true'
|
|
|
|
# Check that default environment variables are still present
|
|
assert 'OPENVSCODE_SERVER_ROOT' in spec.initial_env
|
|
assert 'OH_ENABLE_VNC' in spec.initial_env
|
|
assert 'LOG_JSON' in spec.initial_env
|
|
|
|
def test_docker_specs_override_existing_variables(self):
|
|
"""Test that agent server env variables can override existing ones."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"LOG_JSON": "false", "PYTHONUNBUFFERED": "0"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Agent server env should override the defaults
|
|
assert spec.initial_env['LOG_JSON'] == 'false'
|
|
assert spec.initial_env['PYTHONUNBUFFERED'] == '0'
|
|
|
|
def test_docker_specs_empty_agent_server_env(self):
|
|
"""Test Docker specs when no agent server env variables are set."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Should only have the default environment variables
|
|
expected_defaults = {
|
|
'OPENVSCODE_SERVER_ROOT',
|
|
'OH_ENABLE_VNC',
|
|
'LOG_JSON',
|
|
'OH_CONVERSATIONS_PATH',
|
|
'OH_BASH_EVENTS_DIR',
|
|
'PYTHONUNBUFFERED',
|
|
'ENV_LOG_LEVEL',
|
|
}
|
|
|
|
# All defaults should be present
|
|
for var in expected_defaults:
|
|
assert var in spec.initial_env
|
|
|
|
# No additional variables should be present
|
|
assert set(spec.initial_env.keys()) == expected_defaults
|
|
|
|
|
|
class TestProcessSandboxSpecEnvironmentOverride:
|
|
"""Test environment variable override integration in Process sandbox specs."""
|
|
|
|
def test_process_specs_include_agent_server_env(self):
|
|
"""Test that Process sandbox specs include agent server environment variables."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"PROCESS_VAR": "process_value", "WORKER_COUNT": "4"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_process_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Check that custom environment variables are included
|
|
assert 'PROCESS_VAR' in spec.initial_env
|
|
assert spec.initial_env['PROCESS_VAR'] == 'process_value'
|
|
assert 'WORKER_COUNT' in spec.initial_env
|
|
assert spec.initial_env['WORKER_COUNT'] == '4'
|
|
|
|
# Check that default environment variables are still present
|
|
assert 'OH_ENABLE_VS_CODE' in spec.initial_env
|
|
|
|
def test_process_specs_override_existing_variables(self):
|
|
"""Test that agent server env variables can override existing ones in process specs."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"OH_ENABLE_VS_CODE": "1"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_process_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Agent server env should override the default
|
|
assert spec.initial_env['OH_ENABLE_VS_CODE'] == '1'
|
|
|
|
def test_process_specs_empty_agent_server_env(self):
|
|
"""Test Process specs when no agent server env variables are set."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
specs = get_default_process_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Should only have the default environment variables
|
|
expected_defaults = {
|
|
'OH_ENABLE_VS_CODE',
|
|
}
|
|
|
|
# All defaults should be present
|
|
for var in expected_defaults:
|
|
assert var in spec.initial_env
|
|
|
|
# Should have exactly the expected variables
|
|
assert set(spec.initial_env.keys()) == expected_defaults
|
|
|
|
|
|
class TestRemoteSandboxSpecEnvironmentOverride:
|
|
"""Test environment variable override integration in Remote sandbox specs."""
|
|
|
|
def test_remote_specs_include_agent_server_env(self):
|
|
"""Test that Remote sandbox specs include agent server environment variables."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"REMOTE_VAR": "remote_value", "API_KEY": "secret123"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_remote_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Check that custom environment variables are included
|
|
assert 'REMOTE_VAR' in spec.initial_env
|
|
assert spec.initial_env['REMOTE_VAR'] == 'remote_value'
|
|
assert 'API_KEY' in spec.initial_env
|
|
assert spec.initial_env['API_KEY'] == 'secret123'
|
|
|
|
# Check that default environment variables are still present
|
|
assert 'OH_CONVERSATIONS_PATH' in spec.initial_env
|
|
assert 'OH_BASH_EVENTS_DIR' in spec.initial_env
|
|
assert 'OH_VSCODE_PORT' in spec.initial_env
|
|
|
|
def test_remote_specs_override_existing_variables(self):
|
|
"""Test that agent server env variables can override existing ones in remote specs."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"OH_VSCODE_PORT": "60002", "OH_CONVERSATIONS_PATH": "/custom/conversations"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_remote_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Agent server env should override the defaults
|
|
assert spec.initial_env['OH_VSCODE_PORT'] == '60002'
|
|
assert spec.initial_env['OH_CONVERSATIONS_PATH'] == '/custom/conversations'
|
|
|
|
def test_remote_specs_empty_agent_server_env(self):
|
|
"""Test Remote specs when no agent server env variables are set."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
specs = get_default_remote_sandbox_specs()
|
|
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
# Should have the default environment variables
|
|
expected_defaults = {
|
|
'OH_CONVERSATIONS_PATH',
|
|
'OH_BASH_EVENTS_DIR',
|
|
'OH_VSCODE_PORT',
|
|
'LOG_JSON',
|
|
'OH_ENABLE_VNC',
|
|
'OPENVSCODE_SERVER_ROOT',
|
|
}
|
|
|
|
# All defaults should be present
|
|
for var in expected_defaults:
|
|
assert var in spec.initial_env
|
|
|
|
# Should have exactly the expected variables
|
|
assert set(spec.initial_env.keys()) == expected_defaults
|
|
|
|
|
|
class TestEnvironmentOverrideIntegration:
|
|
"""Integration tests for the complete environment override functionality."""
|
|
|
|
def test_consistent_behavior_across_sandbox_types(self):
|
|
"""Test that environment override behavior is consistent across all sandbox types."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"SHARED_VAR": "shared_value", "INTEGRATION_TEST": "true"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
docker_specs = get_default_docker_sandbox_specs()
|
|
process_specs = get_default_process_sandbox_specs()
|
|
remote_specs = get_default_remote_sandbox_specs()
|
|
|
|
# All sandbox types should include the same custom environment variables
|
|
for specs in [docker_specs, process_specs, remote_specs]:
|
|
assert len(specs) == 1
|
|
spec = specs[0]
|
|
|
|
assert 'SHARED_VAR' in spec.initial_env
|
|
assert spec.initial_env['SHARED_VAR'] == 'shared_value'
|
|
assert 'INTEGRATION_TEST' in spec.initial_env
|
|
assert spec.initial_env['INTEGRATION_TEST'] == 'true'
|
|
|
|
def test_complex_environment_scenario(self):
|
|
"""Test a complex scenario with many environment variables."""
|
|
env_vars = {
|
|
'OH_AGENT_SERVER_ENV': '{"APP_NAME": "MyApp", "APP_VERSION": "1.2.3", "APP_ENV": "production", "DB_HOST": "localhost", "DB_PORT": "5432", "DB_NAME": "myapp_db", "FEATURE_X": "enabled", "FEATURE_Y": "disabled", "LOG_JSON": "false", "PYTHONUNBUFFERED": "0"}',
|
|
# Non-matching variables (should be ignored)
|
|
'OTHER_VAR': 'ignored',
|
|
'OH_OTHER_PREFIX_VAR': 'also_ignored',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
# Test with Docker specs as representative
|
|
specs = get_default_docker_sandbox_specs()
|
|
spec = specs[0]
|
|
|
|
# Custom variables should be present
|
|
assert spec.initial_env['APP_NAME'] == 'MyApp'
|
|
assert spec.initial_env['APP_VERSION'] == '1.2.3'
|
|
assert spec.initial_env['APP_ENV'] == 'production'
|
|
assert spec.initial_env['DB_HOST'] == 'localhost'
|
|
assert spec.initial_env['DB_PORT'] == '5432'
|
|
assert spec.initial_env['DB_NAME'] == 'myapp_db'
|
|
assert spec.initial_env['FEATURE_X'] == 'enabled'
|
|
assert spec.initial_env['FEATURE_Y'] == 'disabled'
|
|
|
|
# Overridden defaults should have new values
|
|
assert spec.initial_env['LOG_JSON'] == 'false'
|
|
assert spec.initial_env['PYTHONUNBUFFERED'] == '0'
|
|
|
|
# Non-matching variables should not be present
|
|
assert 'OTHER_VAR' not in spec.initial_env
|
|
assert 'OH_OTHER_PREFIX_VAR' not in spec.initial_env
|
|
|
|
# Original defaults that weren't overridden should still be present
|
|
assert 'OPENVSCODE_SERVER_ROOT' in spec.initial_env
|
|
assert 'OH_ENABLE_VNC' in spec.initial_env
|
|
|
|
def test_environment_isolation(self):
|
|
"""Test that environment changes don't affect subsequent calls."""
|
|
# First call with some environment variables
|
|
env_vars_1 = {
|
|
'OH_AGENT_SERVER_ENV': '{"VAR1": "value1", "VAR2": "value2"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars_1, clear=True):
|
|
specs_1 = get_default_docker_sandbox_specs()
|
|
spec_1 = specs_1[0]
|
|
|
|
assert 'VAR1' in spec_1.initial_env
|
|
assert 'VAR2' in spec_1.initial_env
|
|
assert spec_1.initial_env['VAR1'] == 'value1'
|
|
assert spec_1.initial_env['VAR2'] == 'value2'
|
|
|
|
# Second call with different environment variables
|
|
env_vars_2 = {
|
|
'OH_AGENT_SERVER_ENV': '{"VAR3": "value3", "VAR4": "value4"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars_2, clear=True):
|
|
specs_2 = get_default_docker_sandbox_specs()
|
|
spec_2 = specs_2[0]
|
|
|
|
# Should only have the new variables
|
|
assert 'VAR3' in spec_2.initial_env
|
|
assert 'VAR4' in spec_2.initial_env
|
|
assert spec_2.initial_env['VAR3'] == 'value3'
|
|
assert spec_2.initial_env['VAR4'] == 'value4'
|
|
|
|
# Should not have the old variables
|
|
assert 'VAR1' not in spec_2.initial_env
|
|
assert 'VAR2' not in spec_2.initial_env
|
|
|
|
|
|
class TestDockerSandboxServiceEnvIntegration:
|
|
"""Integration tests for environment variable propagation to Docker sandbox containers.
|
|
|
|
These tests verify that environment variables are correctly propagated through
|
|
the entire flow from the app-server environment to the agent-server container.
|
|
"""
|
|
|
|
def test_llm_env_vars_propagated_to_container_run(self):
|
|
"""Test that LLM_* env vars are included in docker container.run() environment argument."""
|
|
from unittest.mock import patch
|
|
|
|
# Set up environment with LLM_* variables
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600',
|
|
'LLM_NUM_RETRIES': '10',
|
|
'LLM_MODEL': 'gpt-4',
|
|
'OTHER_VAR': 'should_not_be_forwarded',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
# Create a sandbox spec using the actual factory to get LLM_* vars
|
|
specs = get_default_docker_sandbox_specs()
|
|
sandbox_spec = specs[0]
|
|
|
|
# Verify the sandbox spec has the LLM_* variables
|
|
assert 'LLM_TIMEOUT' in sandbox_spec.initial_env
|
|
assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '3600'
|
|
assert 'LLM_NUM_RETRIES' in sandbox_spec.initial_env
|
|
assert sandbox_spec.initial_env['LLM_NUM_RETRIES'] == '10'
|
|
assert 'LLM_MODEL' in sandbox_spec.initial_env
|
|
assert sandbox_spec.initial_env['LLM_MODEL'] == 'gpt-4'
|
|
# Non-LLM_* variables should not be included
|
|
assert 'OTHER_VAR' not in sandbox_spec.initial_env
|
|
|
|
def test_explicit_oh_agent_server_env_overrides_llm_vars(self):
|
|
"""Test that OH_AGENT_SERVER_ENV can override auto-forwarded LLM_* variables."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600', # Auto-forwarded value
|
|
'OH_AGENT_SERVER_ENV': '{"LLM_TIMEOUT": "7200"}', # Override value
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
sandbox_spec = specs[0]
|
|
|
|
# OH_AGENT_SERVER_ENV should take precedence
|
|
assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '7200'
|
|
|
|
def test_multiple_llm_vars_combined_with_explicit_overrides(self):
|
|
"""Test complex scenario with multiple LLM_* vars and explicit overrides."""
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600',
|
|
'LLM_NUM_RETRIES': '10',
|
|
'LLM_MODEL': 'gpt-4',
|
|
'LLM_TEMPERATURE': '0.7',
|
|
'OH_AGENT_SERVER_ENV': '{"LLM_MODEL": "gpt-3.5-turbo", "CUSTOM_VAR": "custom_value"}',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
sandbox_spec = specs[0]
|
|
|
|
# Auto-forwarded LLM_* vars that weren't overridden
|
|
assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '3600'
|
|
assert sandbox_spec.initial_env['LLM_NUM_RETRIES'] == '10'
|
|
assert sandbox_spec.initial_env['LLM_TEMPERATURE'] == '0.7'
|
|
|
|
# LLM_MODEL should be overridden by OH_AGENT_SERVER_ENV
|
|
assert sandbox_spec.initial_env['LLM_MODEL'] == 'gpt-3.5-turbo'
|
|
|
|
# Custom variable from OH_AGENT_SERVER_ENV
|
|
assert sandbox_spec.initial_env['CUSTOM_VAR'] == 'custom_value'
|
|
|
|
def test_sandbox_spec_env_passed_to_docker_container_run(self):
|
|
"""Test that sandbox spec's initial_env is passed to docker container run."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
|
|
from openhands.app_server.sandbox.docker_sandbox_service import (
|
|
DockerSandboxService,
|
|
ExposedPort,
|
|
)
|
|
|
|
# Create mock docker client
|
|
mock_docker_client = MagicMock()
|
|
mock_container = MagicMock()
|
|
mock_container.name = 'oh-test-abc123'
|
|
mock_container.image.tags = ['test-image:latest']
|
|
mock_container.attrs = {
|
|
'Created': '2024-01-01T00:00:00Z',
|
|
'Config': {
|
|
'Env': ['SESSION_API_KEY=test-key'],
|
|
'WorkingDir': '/workspace',
|
|
},
|
|
'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': '32768'}]}},
|
|
'HostConfig': {'NetworkMode': 'bridge'},
|
|
}
|
|
mock_container.status = 'running'
|
|
mock_docker_client.containers.run.return_value = mock_container
|
|
mock_docker_client.containers.list.return_value = []
|
|
|
|
# Create mock sandbox spec service
|
|
mock_spec_service = MagicMock()
|
|
|
|
# Create sandbox spec with LLM_* environment variables
|
|
env_vars = {
|
|
'LLM_TIMEOUT': '3600',
|
|
'LLM_NUM_RETRIES': '10',
|
|
}
|
|
|
|
with patch.dict(os.environ, env_vars, clear=True):
|
|
specs = get_default_docker_sandbox_specs()
|
|
sandbox_spec = specs[0]
|
|
|
|
mock_spec_service.get_default_sandbox_spec = AsyncMock(
|
|
return_value=sandbox_spec
|
|
)
|
|
|
|
# Create service
|
|
service = DockerSandboxService(
|
|
sandbox_spec_service=mock_spec_service,
|
|
container_name_prefix='oh-test-',
|
|
host_port=3000,
|
|
container_url_pattern='http://localhost:{port}',
|
|
mounts=[],
|
|
exposed_ports=[
|
|
ExposedPort(
|
|
name='AGENT_SERVER',
|
|
description='Agent server',
|
|
container_port=8000,
|
|
)
|
|
],
|
|
health_check_path='/health',
|
|
httpx_client=MagicMock(spec=httpx.AsyncClient),
|
|
max_num_sandboxes=5,
|
|
docker_client=mock_docker_client,
|
|
)
|
|
|
|
# Start sandbox
|
|
import asyncio
|
|
|
|
asyncio.get_event_loop().run_until_complete(service.start_sandbox())
|
|
|
|
# Verify docker was called with environment variables including LLM_*
|
|
call_kwargs = mock_docker_client.containers.run.call_args[1]
|
|
container_env = call_kwargs['environment']
|
|
|
|
# LLM_* variables should be in the container environment
|
|
assert 'LLM_TIMEOUT' in container_env
|
|
assert container_env['LLM_TIMEOUT'] == '3600'
|
|
assert 'LLM_NUM_RETRIES' in container_env
|
|
assert container_env['LLM_NUM_RETRIES'] == '10'
|
|
|
|
# Default variables should also be present
|
|
assert 'OPENVSCODE_SERVER_ROOT' in container_env
|
|
assert 'LOG_JSON' in container_env
|
|
|
|
def test_host_network_mode_with_env_var(self):
|
|
"""Test that AGENT_SERVER_USE_HOST_NETWORK affects container network mode."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
|
|
from openhands.app_server.sandbox.docker_sandbox_service import (
|
|
DockerSandboxService,
|
|
ExposedPort,
|
|
_get_use_host_network_default,
|
|
)
|
|
|
|
# Test with environment variable set
|
|
with patch.dict(
|
|
os.environ, {'AGENT_SERVER_USE_HOST_NETWORK': 'true'}, clear=True
|
|
):
|
|
assert _get_use_host_network_default() is True
|
|
|
|
# Create mock docker client
|
|
mock_docker_client = MagicMock()
|
|
mock_container = MagicMock()
|
|
mock_container.name = 'oh-test-abc123'
|
|
mock_container.image.tags = ['test-image:latest']
|
|
mock_container.attrs = {
|
|
'Created': '2024-01-01T00:00:00Z',
|
|
'Config': {
|
|
'Env': ['SESSION_API_KEY=test-key'],
|
|
'WorkingDir': '/workspace',
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
'HostConfig': {'NetworkMode': 'host'},
|
|
}
|
|
mock_container.status = 'running'
|
|
mock_docker_client.containers.run.return_value = mock_container
|
|
mock_docker_client.containers.list.return_value = []
|
|
|
|
# Create mock sandbox spec service
|
|
mock_spec_service = MagicMock()
|
|
specs = get_default_docker_sandbox_specs()
|
|
mock_spec_service.get_default_sandbox_spec = AsyncMock(
|
|
return_value=specs[0]
|
|
)
|
|
|
|
# Create service with host network enabled
|
|
service = DockerSandboxService(
|
|
sandbox_spec_service=mock_spec_service,
|
|
container_name_prefix='oh-test-',
|
|
host_port=3000,
|
|
container_url_pattern='http://localhost:{port}',
|
|
mounts=[],
|
|
exposed_ports=[
|
|
ExposedPort(
|
|
name='AGENT_SERVER',
|
|
description='Agent server',
|
|
container_port=8000,
|
|
)
|
|
],
|
|
health_check_path='/health',
|
|
httpx_client=MagicMock(spec=httpx.AsyncClient),
|
|
max_num_sandboxes=5,
|
|
docker_client=mock_docker_client,
|
|
use_host_network=True,
|
|
)
|
|
|
|
# Start sandbox
|
|
import asyncio
|
|
|
|
asyncio.get_event_loop().run_until_complete(service.start_sandbox())
|
|
|
|
# Verify docker was called with host network mode
|
|
call_kwargs = mock_docker_client.containers.run.call_args[1]
|
|
assert call_kwargs['network_mode'] == 'host'
|
|
# Port mappings should be None in host network mode
|
|
assert call_kwargs['ports'] is None
|
|
|
|
def test_bridge_network_mode_without_env_var(self):
|
|
"""Test that default (bridge) network mode is used when env var is not set."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
|
|
from openhands.app_server.sandbox.docker_sandbox_service import (
|
|
DockerSandboxService,
|
|
ExposedPort,
|
|
_get_use_host_network_default,
|
|
)
|
|
|
|
# Test without environment variable
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
assert _get_use_host_network_default() is False
|
|
|
|
# Create mock docker client
|
|
mock_docker_client = MagicMock()
|
|
mock_container = MagicMock()
|
|
mock_container.name = 'oh-test-abc123'
|
|
mock_container.image.tags = ['test-image:latest']
|
|
mock_container.attrs = {
|
|
'Created': '2024-01-01T00:00:00Z',
|
|
'Config': {
|
|
'Env': ['SESSION_API_KEY=test-key'],
|
|
'WorkingDir': '/workspace',
|
|
},
|
|
'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': '32768'}]}},
|
|
'HostConfig': {'NetworkMode': 'bridge'},
|
|
}
|
|
mock_container.status = 'running'
|
|
mock_docker_client.containers.run.return_value = mock_container
|
|
mock_docker_client.containers.list.return_value = []
|
|
|
|
# Create mock sandbox spec service
|
|
mock_spec_service = MagicMock()
|
|
specs = get_default_docker_sandbox_specs()
|
|
mock_spec_service.get_default_sandbox_spec = AsyncMock(
|
|
return_value=specs[0]
|
|
)
|
|
|
|
# Create service with bridge network (default)
|
|
service = DockerSandboxService(
|
|
sandbox_spec_service=mock_spec_service,
|
|
container_name_prefix='oh-test-',
|
|
host_port=3000,
|
|
container_url_pattern='http://localhost:{port}',
|
|
mounts=[],
|
|
exposed_ports=[
|
|
ExposedPort(
|
|
name='AGENT_SERVER',
|
|
description='Agent server',
|
|
container_port=8000,
|
|
)
|
|
],
|
|
health_check_path='/health',
|
|
httpx_client=MagicMock(spec=httpx.AsyncClient),
|
|
max_num_sandboxes=5,
|
|
docker_client=mock_docker_client,
|
|
use_host_network=False,
|
|
)
|
|
|
|
# Start sandbox
|
|
import asyncio
|
|
|
|
asyncio.get_event_loop().run_until_complete(service.start_sandbox())
|
|
|
|
# Verify docker was called with bridge network mode (network_mode=None)
|
|
call_kwargs = mock_docker_client.containers.run.call_args[1]
|
|
assert call_kwargs['network_mode'] is None
|
|
# Port mappings should be present in bridge mode
|
|
assert call_kwargs['ports'] is not None
|
|
assert 8000 in call_kwargs['ports']
|