Compare commits

..

3 Commits

Author SHA1 Message Date
enyst 8316ec9eb3 Integrate AuthSystem docs into documentation navigation
- Add AuthSystem design documents to docs.json navigation under Architecture section
- Convert .md files to .mdx format for proper documentation system integration
- Add frontmatter with appropriate titles for both documents
- Documents now accessible in deployed documentation under OpenHands Developers > Architecture
2025-09-04 07:43:29 +00:00
enyst 059e5b7af6 Clean up AuthSystem docs: remove emojis, fix token references, focus on architectural benefits
- Remove all emojis for professional presentation
- Fix provider_tokens references (was dependency injection, not route parameters)
- Streamline benefits section to focus on architectural/codebase/extensibility improvements
- Remove promotional language and focus on technical merits
- Maintain clear documentation of the design and implementation approach
2025-09-04 07:06:12 +00:00
enyst d858882c3d Add AuthSystem design documentation
- Comprehensive AuthSystem design supporting None/SU/MU strategies
- Based on GitHub issues #10751 and #10730 analysis
- Includes before/after code examples and Mermaid diagrams
- Provides clean abstraction boundaries and migration plan
- Extension points for custom builds with multi-user support

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-04 06:54:53 +00:00
17 changed files with 2296 additions and 1949 deletions
-277
View File
@@ -1,277 +0,0 @@
# Migration Guide: From Shared Globals to Context System
This guide explains how to migrate from the deprecated `openhands.server.shared` globals to the new context system.
## Overview
The new context system replaces global variables with dependency injection, providing:
- **Better testability**: Easy to mock dependencies in tests
- **SaaS extensibility**: Custom contexts for multi-tenant scenarios
- **Per-request contexts**: Different configurations per request
- **No import-time side effects**: Lazy initialization of dependencies
- **Type safety**: Better IDE support and type checking
## Quick Migration
### Before (Deprecated)
```python
from openhands.server.shared import config, server_config, file_store, sio
def my_function():
# Use global variables
workspace_dir = config.workspace_dir
app_mode = server_config.app_mode
file_store.save_file(...)
```
### After (Recommended)
```python
from fastapi import Depends, Request
from openhands.server.context import get_server_context, ServerContext
@app.get('/my-endpoint')
async def my_endpoint(
request: Request,
context: ServerContext = Depends(get_server_context)
):
# Use context instead of globals
config = context.get_config()
server_config = context.get_server_config()
file_store = context.get_file_store()
workspace_dir = config.workspace_dir
app_mode = server_config.app_mode
file_store.save_file(...)
```
## Detailed Migration Steps
### 1. Route Handlers
**Before:**
```python
from openhands.server.shared import config, conversation_manager
@app.post('/conversations')
async def create_conversation(request: ConversationRequest):
conversation = conversation_manager.create_conversation(
request.user_id,
config.default_agent
)
return conversation
```
**After:**
```python
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.post('/conversations')
async def create_conversation(
request: ConversationRequest,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
conversation_manager = context.get_conversation_manager()
conversation = conversation_manager.create_conversation(
request.user_id,
config.default_agent
)
return conversation
```
### 2. Service Classes
**Before:**
```python
from openhands.server.shared import file_store, monitoring_listener
class MyService:
def process_file(self, file_path: str):
content = file_store.read(file_path)
monitoring_listener.log_event('file_processed')
return content
```
**After:**
```python
from openhands.server.context import ServerContext
class MyService:
def __init__(self, context: ServerContext):
self.context = context
def process_file(self, file_path: str):
file_store = self.context.get_file_store()
monitoring_listener = self.context.get_monitoring_listener()
content = file_store.read(file_path)
monitoring_listener.log_event('file_processed')
return content
# In route handler:
@app.post('/process')
async def process_endpoint(
request: ProcessRequest,
context: ServerContext = Depends(get_server_context)
):
service = MyService(context)
return service.process_file(request.file_path)
```
### 3. Store Classes
**Before:**
```python
from openhands.server.shared import SettingsStoreImpl
def get_user_settings(user_id: str):
store = SettingsStoreImpl(user_id)
return store.load()
```
**After:**
```python
from openhands.server.context import ServerContext
def get_user_settings(user_id: str, context: ServerContext):
SettingsStoreClass = context.get_settings_store_class()
store = SettingsStoreClass(user_id)
return store.load()
# In route handler:
@app.get('/settings/{user_id}')
async def get_settings(
user_id: str,
context: ServerContext = Depends(get_server_context)
):
return get_user_settings(user_id, context)
```
### 4. Testing
**Before:**
```python
import pytest
from unittest.mock import patch
def test_my_function():
with patch('openhands.server.shared.config') as mock_config:
mock_config.workspace_dir = '/test'
result = my_function()
assert result == expected
```
**After:**
```python
import pytest
from openhands.server.context import create_server_context
class MockServerContext:
def get_config(self):
mock_config = Mock()
mock_config.workspace_dir = '/test'
return mock_config
def test_my_function():
context = MockServerContext()
result = my_function(context)
assert result == expected
```
## SaaS Extension Example
The new context system makes it easy to extend OpenHands for SaaS scenarios:
```python
from openhands.server.context import ServerContext, set_context_class
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
self.user_id = user_id
self.org_id = org_id
def get_file_store(self):
# Return tenant-isolated file store
return MultiTenantFileStore(self.user_id, self.org_id)
def get_server_config(self):
# Return SaaS-specific configuration
return SaaSServerConfig(org_id=self.org_id)
# Configure globally
set_context_class('myapp.context.SaaSServerContext')
# Use in routes with tenant context
@app.get('/tenant/{org_id}/files')
async def get_tenant_files(
org_id: str,
context: SaaSServerContext = Depends(get_server_context)
):
file_store = context.get_file_store()
return file_store.list_files()
```
## Migration Checklist
- [ ] Replace `from openhands.server.shared import ...` with context injection
- [ ] Update route handlers to use `Depends(get_server_context)`
- [ ] Modify service classes to accept `ServerContext` parameter
- [ ] Update tests to use mock contexts instead of patching globals
- [ ] Remove direct imports of shared globals
- [ ] Test that all functionality still works
## Backward Compatibility
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings when imported. The globals are now implemented using the new context system internally.
## Benefits After Migration
1. **Better Testing**: Easy to mock dependencies without patching globals
2. **Type Safety**: Better IDE support and type checking
3. **Extensibility**: Easy to create custom contexts for different scenarios
4. **Performance**: Lazy initialization reduces startup time
5. **Maintainability**: Clear dependency relationships
## Common Issues
### Issue: Import errors during migration
**Solution**: Make sure to import the context system correctly:
```python
from openhands.server.context import get_server_context, ServerContext
```
### Issue: Context not available in non-route functions
**Solution**: Pass the context as a parameter:
```python
def helper_function(data: str, context: ServerContext):
config = context.get_config()
# ... use config
```
### Issue: Testing becomes more complex
**Solution**: Create reusable mock contexts:
```python
# test_utils.py
class TestServerContext(ServerContext):
def __init__(self):
self.mock_config = create_mock_config()
self.mock_file_store = create_mock_file_store()
def get_config(self):
return self.mock_config
def get_file_store(self):
return self.mock_file_store
```
## Getting Help
If you encounter issues during migration:
1. Check the examples in `examples/saas_extension.py`
2. Look at the implementation in `openhands/server/context/`
3. Review existing route handlers that have been migrated
4. Create an issue if you find bugs or need clarification
-230
View File
@@ -1,230 +0,0 @@
# OpenHands Server Context Refactoring Plan
## Problem Statement
The current OpenHands architecture has globals in `server/shared.py` that are initialized at import time based on environment variables. This creates several issues for the SaaS version:
1. **Import-time dependencies**: All globals are created when modules are imported
2. **Hard to extend**: SaaS can't easily override or extend components
3. **CI/CD issues**: Everything depends on env vars being set correctly at import time
4. **Per-user behavior**: Difficult to implement per-user/per-request behavior
5. **Outside repo issues**: Hard to run SaaS from outside repo due to import dependencies
## Current Problematic Globals
From `openhands/server/shared.py`:
- `config: OpenHandsConfig` - Core app configuration
- `server_config: ServerConfig` - Server-specific configuration
- `file_store: FileStore` - File storage implementation
- `sio: socketio.AsyncServer` - Socket.IO server instance
- `conversation_manager` - Conversation management implementation
- `monitoring_listener` - Monitoring implementation
- `SettingsStoreImpl`, `SecretsStoreImpl`, `ConversationStoreImpl` - Storage implementations
## Solution: ServerContext Pattern
### 1. Create ServerContext Base Class
Create `openhands/server/context/server_context.py`:
```python
from abc import ABC, abstractmethod
from typing import Optional
import socketio
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.storage.files import FileStore
# ... other imports
class ServerContext(ABC):
"""Base class for server context that holds all server dependencies.
This replaces the global variables in shared.py and allows for:
- Dependency injection
- Easy extensibility for SaaS
- Per-request contexts
- Testability
"""
def __init__(self):
self._config: Optional[OpenHandsConfig] = None
self._server_config: Optional[ServerConfig] = None
self._file_store: Optional[FileStore] = None
# ... other cached instances
@abstractmethod
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration"""
@abstractmethod
def get_server_config(self) -> ServerConfig:
"""Get the server configuration"""
@abstractmethod
def get_file_store(self) -> FileStore:
"""Get the file store implementation"""
# ... other abstract methods for all current globals
```
### 2. Create Default Implementation
Create `openhands/server/context/default_server_context.py`:
```python
class DefaultServerContext(ServerContext):
"""Default implementation that maintains current behavior"""
def get_config(self) -> OpenHandsConfig:
if self._config is None:
self._config = load_openhands_config()
return self._config
def get_server_config(self) -> ServerConfig:
if self._server_config is None:
self._server_config = load_server_config()
return self._server_config
# ... implement all methods with current logic
```
### 3. Context Provider System
Create `openhands/server/context/context_provider.py`:
```python
from fastapi import Request
from openhands.utils.import_utils import get_impl
_context_class: Optional[str] = None
def set_context_class(context_class: str):
"""Set the server context class to use"""
global _context_class
_context_class = context_class
async def get_server_context(request: Request) -> ServerContext:
"""Get server context from request, with caching"""
context = getattr(request.state, 'server_context', None)
if context:
return context
# Use configured context class or default
context_cls_name = _context_class or 'openhands.server.context.default_server_context.DefaultServerContext'
context_cls = get_impl(ServerContext, context_cls_name)
context = context_cls()
request.state.server_context = context
return context
```
### 4. Update Shared.py (Backward Compatibility)
Keep `shared.py` for backward compatibility but make it use the context:
```python
# openhands/server/shared.py
from openhands.server.context.default_server_context import DefaultServerContext
# Create default context for backward compatibility
_default_context = DefaultServerContext()
# Expose globals for backward compatibility
config = _default_context.get_config()
server_config = _default_context.get_server_config()
file_store = _default_context.get_file_store()
# ... etc
```
### 5. Update Routes to Use Context
Update all route files to use dependency injection:
```python
# Example: openhands/server/routes/settings.py
from openhands.server.context import get_server_context
@app.get('/settings')
async def get_settings(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use config instead of importing from shared
```
## Benefits for SaaS
### 1. Easy Extension
SaaS can create their own context:
```python
# In SaaS repo: saas/server_context.py
from openhands.server.context import ServerContext
class SaaSServerContext(ServerContext):
def get_server_config(self) -> ServerConfig:
# Return SaaS-specific config with enterprise features
return SaaSServerConfig()
def get_conversation_manager(self) -> ConversationManager:
# Return multi-tenant conversation manager
return MultiTenantConversationManager()
```
### 2. Per-Request Contexts
SaaS can implement per-user contexts:
```python
class PerUserServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
super().__init__()
self.user_id = user_id
self.org_id = org_id
def get_file_store(self) -> FileStore:
# Return user-specific file store
return UserFileStore(self.user_id, self.org_id)
```
### 3. No Import-Time Dependencies
SaaS can run without setting environment variables at import time:
```python
# In SaaS startup
from openhands.server.context import set_context_class
set_context_class('saas.server_context.SaaSServerContext')
```
## Migration Strategy
### Phase 1: Create Context System
1. Create ServerContext base class and default implementation
2. Create context provider system
3. Update shared.py for backward compatibility
### Phase 2: Update Routes Gradually
1. Update one route at a time to use context injection
2. Test each route to ensure no regressions
3. Keep backward compatibility during transition
### Phase 3: Clean Up
1. Remove globals from shared.py once all routes are updated
2. Update documentation
3. Create examples for SaaS extension
## Implementation Order
1. `openhands/server/context/server_context.py` - Base class
2. `openhands/server/context/default_server_context.py` - Default implementation
3. `openhands/server/context/context_provider.py` - Provider system
4. `openhands/server/context/__init__.py` - Public API
5. Update `openhands/server/shared.py` for backward compatibility
6. Update routes one by one to use context injection
7. Update tests to use context system
8. Documentation and examples
This approach provides a clean migration path while maintaining backward compatibility and enabling the SaaS extensibility requirements.
-206
View File
@@ -1,206 +0,0 @@
# OpenHands Server Globals Refactoring - Summary
## Overview
Successfully refactored OpenHands server globals in `shared.py` and `server_config.py` to enable SaaS extensibility without import-time dependencies. The refactoring introduces a dependency injection pattern using a `ServerContext` system that maintains backward compatibility while enabling multi-tenant SaaS scenarios.
## Problem Solved
### Before Refactoring
- **Global variables on import**: `shared.py` created globals like `config`, `server_config`, `file_store`, `sio`, etc. on module import
- **Import-time side effects**: Loading the module triggered configuration loading and dependency initialization
- **SaaS integration issues**: External SaaS repos had CI/CD problems due to environment variable dependencies
- **Testing difficulties**: Hard to mock dependencies due to global state
- **No extensibility**: Impossible to customize behavior for different tenants or environments
### After Refactoring
- **Dependency injection**: Clean `ServerContext` pattern with lazy initialization
- **No import-time side effects**: Dependencies only loaded when actually needed
- **SaaS extensibility**: Easy to create custom contexts for multi-tenant scenarios
- **Better testability**: Easy to mock contexts for testing
- **Backward compatibility**: Existing code continues to work with deprecation warnings
## Architecture Changes
### New Context System
```
openhands/server/context/
├── __init__.py # Public API
├── server_context.py # Abstract base class
├── default_server_context.py # Default implementation
└── context_provider.py # Dependency injection system
```
### Key Components
1. **ServerContext (Abstract Base Class)**
- Defines interface for all server dependencies
- 9 abstract methods for different dependency types
- Extensible for SaaS implementations
2. **DefaultServerContext**
- Maintains exact behavior of original shared.py
- Lazy initialization of all dependencies
- No import-time side effects
3. **Context Provider System**
- `get_server_context()` for FastAPI dependency injection
- `set_context_class()` for global configuration
- `create_server_context()` for testing/CLI usage
4. **Backward Compatibility Layer**
- `shared.py` now uses `__getattr__` for lazy loading
- All existing imports continue to work
- Deprecation warnings guide migration
## SaaS Extensibility
### Multi-Tenant Context Example
```python
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
self.user_id = user_id
self.org_id = org_id
def get_file_store(self):
# Return tenant-isolated file store
return MultiTenantFileStore(self.user_id, self.org_id)
def get_server_config(self):
# Return SaaS-specific configuration
return SaaSServerConfig(org_id=self.org_id)
# Configure globally
set_context_class('myapp.context.SaaSServerContext')
```
### Benefits for SaaS
- **Per-tenant isolation**: Different storage, config, and features per organization
- **Enterprise features**: Easy to add billing, advanced monitoring, etc.
- **Scalable architecture**: Context per request enables horizontal scaling
- **Clean separation**: SaaS code stays in external repo, extends OpenHands cleanly
## Migration Path
### For OpenHands Core
- **Phase 1**: Refactoring complete, backward compatibility maintained
- **Phase 2**: Gradually migrate routes to use dependency injection
- **Phase 3**: Remove deprecated shared.py (future release)
### For SaaS Implementations
- **Immediate**: Can use new context system for new features
- **Gradual**: Migrate existing code using migration guide
- **Benefits**: Cleaner architecture, better testing, easier deployment
## Files Created/Modified
### New Files
- `openhands/server/context/__init__.py` - Public API
- `openhands/server/context/server_context.py` - Abstract base class
- `openhands/server/context/default_server_context.py` - Default implementation
- `openhands/server/context/context_provider.py` - Dependency injection
- `examples/saas_extension.py` - SaaS extension example
- `MIGRATION_GUIDE.md` - Detailed migration instructions
- `test_refactor.py` - Comprehensive test suite
### Modified Files
- `openhands/server/shared.py` - Backward compatibility layer
## Testing Results
Comprehensive test suite with 5 test categories:
1.**Context System**: Import, creation, class switching
2.**Backward Compatibility**: Lazy loading, attribute access
3.**Abstract Base Class**: Proper abstraction, required methods
4.**Default Context**: Instantiation, method availability
5.**SaaS Example**: Multi-tenant context structure
**Result: 5/5 tests passed** 🎉
## Usage Examples
### New Way (Recommended)
```python
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.get('/conversations')
async def get_conversations(
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
conversation_manager = context.get_conversation_manager()
return conversation_manager.list_conversations()
```
### Old Way (Still Works)
```python
from openhands.server.shared import config, conversation_manager
@app.get('/conversations')
async def get_conversations():
# Shows deprecation warning but works
return conversation_manager.list_conversations()
```
### SaaS Extension
```python
# In SaaS application startup
from openhands.server.context import set_context_class
set_context_class('myapp.context.SaaSServerContext')
# Routes automatically get tenant-aware context
@app.get('/tenant/{org_id}/conversations')
async def get_tenant_conversations(
org_id: str,
context: SaaSServerContext = Depends(get_server_context)
):
# context.org_id and context.user_id available
# All dependencies are tenant-isolated
conversation_manager = context.get_conversation_manager()
return conversation_manager.list_conversations()
```
## Benefits Achieved
### For OpenHands Core
-**Better Architecture**: Clean dependency injection pattern
-**Improved Testing**: Easy to mock dependencies
-**No Breaking Changes**: Full backward compatibility
-**Performance**: Lazy loading reduces startup time
-**Type Safety**: Better IDE support and type checking
### For SaaS Implementations
-**Multi-Tenancy**: Per-organization contexts and isolation
-**Extensibility**: Easy to add enterprise features
-**Clean Integration**: No need to fork OpenHands
-**Deployment Flexibility**: Can run from external repos
-**CI/CD Fixes**: No more environment variable dependencies
### For Development
-**Maintainability**: Clear dependency relationships
-**Debugging**: Easier to trace dependency issues
-**Documentation**: Clear migration path and examples
-**Future-Proof**: Extensible architecture for new features
## Next Steps
1. **Immediate**: Refactoring is complete and tested
2. **Short-term**: Begin migrating core routes to use dependency injection
3. **Medium-term**: SaaS implementations can adopt new context system
4. **Long-term**: Remove deprecated shared.py in future major release
## Conclusion
The refactoring successfully addresses all the original problems:
-**Import-time dependencies** → ✅ **Lazy initialization**
-**Global state pollution** → ✅ **Clean dependency injection**
-**SaaS integration issues** → ✅ **Multi-tenant context system**
-**Testing difficulties** → ✅ **Easy mocking and testing**
-**No extensibility** → ✅ **Pluggable context implementations**
The new architecture enables OpenHands to support SaaS scenarios while maintaining full backward compatibility and improving the overall codebase quality.
-272
View File
@@ -1,272 +0,0 @@
# OpenHands Extensibility Migration Guide
This guide explains how to migrate from the old global variable approach to the new factory-based extensibility system.
## Overview
OpenHands has been refactored to eliminate import-time dependencies on environment variables and global state. This enables external repositories to cleanly extend OpenHands without configuration conflicts.
## The Problem We Solved
### Before (Problematic)
```python
# In OpenHands shared.py - loaded at import time
config = Config() # Reads environment variables
server_config = ServerConfig() # More environment variables
# External repos had to:
# 1. Set environment variables before importing OpenHands
# 2. Deal with global state conflicts
# 3. Couldn't easily override specific behaviors
```
### After (Clean)
```python
# External repos can now:
from openhands.server.factory import create_openhands_app
app = create_openhands_app(
context_factory=lambda: MyCustomContext(),
include_oss_routes=False
)
```
## Migration Paths
### 1. For External Repositories (Recommended)
**Old Way (Don't do this):**
```python
# external_repo/main.py
import os
os.environ['OPENHANDS_CONFIG_CLS'] = 'my_config.MyConfig'
os.environ['CONVERSATION_MANAGER_CLASS'] = 'my_manager.MyManager'
from openhands.server.app import app # Imports with global state
```
**New Way (Recommended):**
```python
# external_repo/main.py
from openhands.server.factory import create_openhands_app
from external_repo.context import ExternalRepoContext
def create_app():
return create_openhands_app(
context_factory=lambda: ExternalRepoContext(),
include_oss_routes=False, # Skip OSS-specific routes
title='My Enterprise Platform'
)
app = create_app()
# Add your own routes
@app.get('/enterprise/dashboard')
async def dashboard():
return {'status': 'enterprise'}
```
### 2. For OpenHands Core Development
**Old Way:**
```python
# In route handlers
from openhands.server.shared import config, server_config
@app.get('/example')
async def example_route():
storage_path = config.workspace_base
app_mode = server_config.app_mode
```
**New Way:**
```python
# In route handlers
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.get('/example')
async def example_route(
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
server_config = context.get_server_config()
storage_path = config.workspace_base
app_mode = server_config.app_mode
```
## Custom Context Implementation
### Step 1: Create Your Context Class
```python
# my_extension/context.py
from openhands.server.context.server_context import ServerContext
class MyCustomContext(ServerContext):
def __init__(self, tenant_id: str = 'default'):
super().__init__()
self.tenant_id = tenant_id
def get_config(self):
"""Override with tenant-specific configuration."""
config = super().get_config()
config.workspace_base = f'/data/tenants/{self.tenant_id}/workspace'
return config
def get_server_config(self):
"""Override server configuration."""
server_config = super().get_server_config()
server_config.app_mode = 'ENTERPRISE'
server_config.enable_billing = True
return server_config
```
### Step 2: Create Your FastAPI App
```python
# my_extension/app.py
from openhands.server.factory import create_openhands_app
from my_extension.context import MyCustomContext
def create_my_app():
# Option A: Extend OpenHands app directly
app = create_openhands_app(
context_factory=lambda: MyCustomContext(),
title='My Enterprise Platform'
)
# Add your routes
@app.get('/enterprise/status')
async def enterprise_status():
return {'mode': 'enterprise'}
return app
# Option B: Create your own app and mount OpenHands
from fastapi import FastAPI
def create_my_app_with_mount():
main_app = FastAPI(title='My Platform')
openhands_app = create_openhands_app(
context_factory=lambda: MyCustomContext()
)
main_app.mount('/openhands', openhands_app)
@main_app.get('/my-dashboard')
async def dashboard():
return {'dashboard': 'data'}
return main_app
```
### Step 3: Run Your Application
```python
# my_extension/main.py
import uvicorn
from my_extension.app import create_my_app
if __name__ == '__main__':
app = create_my_app()
uvicorn.run(app, host='0.0.0.0', port=8000)
```
## Advanced Patterns
### Multi-Tenant Context
```python
class MultiTenantContext(ServerContext):
def __init__(self, request: Request):
super().__init__()
# Extract tenant from request
self.tenant_id = request.headers.get('X-Tenant-ID', 'default')
def get_file_store(self):
# Return tenant-isolated file store
return TenantFileStore(tenant_id=self.tenant_id)
# Use with factory
def create_tenant_context(request: Request):
return MultiTenantContext(request)
app = create_openhands_app(
context_factory=create_tenant_context
)
```
### Custom Lifespan Management
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def my_lifespan(app: FastAPI):
# Startup
print("Starting my custom services...")
await initialize_my_database()
yield
# Shutdown
print("Shutting down my custom services...")
await cleanup_my_database()
app = create_openhands_app(
context_factory=MyContext,
custom_lifespan=my_lifespan
)
```
## Testing Your Extension
```python
# tests/test_my_extension.py
from fastapi.testclient import TestClient
from my_extension.app import create_my_app
def test_my_extension():
app = create_my_app()
client = TestClient(app)
# Test your custom routes
response = client.get('/enterprise/status')
assert response.status_code == 200
assert response.json()['mode'] == 'enterprise'
# Test OpenHands routes still work
response = client.get('/api/health')
assert response.status_code == 200
```
## Benefits of the New Approach
1. **No Environment Variables**: Configuration is done through code, not environment variables
2. **Clean Separation**: External repos don't modify OpenHands globals
3. **Dependency Injection**: Proper FastAPI dependency injection patterns
4. **Testability**: Easy to mock contexts for testing
5. **Flexibility**: Can create multiple apps with different configurations
6. **No Import-Time Side Effects**: Safe to import OpenHands modules
## Backward Compatibility
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings and should be migrated to the new context system.
## Common Pitfalls
1. **Don't set environment variables**: Use the factory pattern instead
2. **Don't import `openhands.server.app` directly**: Use the factory to create your own app
3. **Don't modify global state**: Use dependency injection through contexts
4. **Don't forget to override dependencies**: Use `app.dependency_overrides` if needed
## Getting Help
If you need help migrating your extension, please:
1. Check the examples in `examples/external_repo_extension.py`
2. Look at the test cases for patterns
3. Open an issue with your specific use case
The new system is designed to be more flexible and maintainable while enabling clean extensibility for all types of OpenHands deployments.
+3 -1
View File
@@ -153,7 +153,9 @@
"group": "Architecture",
"pages": [
"usage/architecture/backend",
"usage/architecture/runtime"
"usage/architecture/runtime",
"usage/architecture/auth-system-summary",
"usage/architecture/auth-system-design"
]
},
"usage/how-to/debugging",
@@ -0,0 +1,856 @@
# OpenHands AuthSystem Design
## Executive Summary
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
## Problem Statement
### Current Issues
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
- Storage partitioning (`users/{user_id}/` paths)
- Conversation/session scoping
- API route dependencies
- Provider token resolution
- Data model fields
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
### Requirements from GitHub Issues
From **Issue #10751** (user_id audit):
- Support None, SU, and MU modes
- Introduce `UserContext` and `StorageNamespace` abstractions
- Remove redundant `if user_id` guards (7 identified)
- Clean up storage path helpers
From **Issue #10730** (token provider):
- Remove `provider_tokens` dependency injection
- Introduce `TokenProvider` boundary abstraction
- Support backend-only credential resolution
- Enable custom builds with token refresh/rotation patterns
## Solution Architecture
### Core Components
#### 1. AuthStrategy Interface
```python
# openhands/auth/strategies/base.py
from abc import ABC, abstractmethod
from typing import Optional
from fastapi import Request
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
class AuthStrategy(ABC):
"""Base class for authentication strategies"""
@abstractmethod
def get_name(self) -> str:
"""Return strategy name for logging/debugging"""
@abstractmethod
def requires_auth(self) -> bool:
"""Whether this strategy requires user authentication"""
@abstractmethod
async def authenticate(self, request: Request) -> Optional[UserContext]:
"""Authenticate request and return UserContext or None"""
@abstractmethod
async def get_token_provider(self, request: Request) -> TokenProvider:
"""Get token provider for this request"""
@abstractmethod
def get_login_url(self) -> Optional[str]:
"""Get login URL for frontend, None if no auth required"""
```
#### 2. UserContext
```python
# openhands/auth/user_context.py
from dataclasses import dataclass
from typing import Optional
from datetime import datetime
@dataclass(frozen=True)
class UserContext:
"""Immutable user context for authenticated requests"""
user_id: str
email: Optional[str] = None
username: Optional[str] = None
github_id: Optional[int] = None
github_username: Optional[str] = None
is_admin: bool = False
created_at: Optional[datetime] = None
last_login: Optional[datetime] = None
@property
def storage_namespace(self) -> str:
"""Get storage namespace for this user"""
return self.user_id
```
#### 3. TokenProvider Interface
```python
# openhands/auth/token_provider.py
from abc import ABC, abstractmethod
from typing import Optional, Mapping
from openhands.integrations.service_types import ProviderType
from openhands.integrations.provider import ProviderToken
class TokenProvider(ABC):
"""Abstract token provider for git integrations"""
@abstractmethod
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
"""Get token for specific provider"""
@abstractmethod
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
"""Get all available provider tokens"""
```
#### 4. StorageNamespace
```python
# openhands/auth/storage_namespace.py
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class StorageNamespace:
"""Encapsulates storage path logic for user data"""
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
def get_conversation_events_dir(self, sid: str) -> str:
return f'{self.get_conversation_dir(sid)}events/'
def get_conversation_metadata_filename(self, sid: str) -> str:
return f'{self.get_conversation_dir(sid)}metadata.json'
# ... other path methods
```
### Authentication Strategies
#### 1. None Strategy (Current Behavior)
```python
# openhands/auth/strategies/none_strategy.py
from typing import Optional
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
class NoneStrategy(AuthStrategy):
"""No authentication - current OpenHands behavior"""
def get_name(self) -> str:
return "none"
def requires_auth(self) -> bool:
return False
async def authenticate(self, request: Request) -> Optional[UserContext]:
return None # No user context
async def get_token_provider(self, request: Request) -> TokenProvider:
return DefaultTokenProvider() # Uses secrets.json
def get_login_url(self) -> Optional[str]:
return None
```
#### 2. Single User Strategy
```python
# openhands/auth/strategies/single_user_strategy.py
from typing import Optional
from fastapi import Request, HTTPException
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
from openhands.server.shared import server_config
class SingleUserStrategy(AuthStrategy):
"""Single user with GitHub OAuth"""
def get_name(self) -> str:
return "single_user"
def requires_auth(self) -> bool:
return server_config.enable_su_auth
async def authenticate(self, request: Request) -> Optional[UserContext]:
if not self.requires_auth():
# SU mode without auth - create virtual user
return UserContext(
user_id="local",
username="local_user",
is_admin=True
)
# Extract JWT token from cookie/header
token = self._extract_token(request)
if not token:
return None
# Validate JWT and extract user info
user_data = self._validate_jwt(token)
if not user_data:
return None
# Verify user is allowed (if configured)
if (server_config.su_github_username and
user_data.get('github_username') != server_config.su_github_username):
raise HTTPException(403, "Access denied")
return UserContext(
user_id=user_data['github_username'],
email=user_data.get('email'),
username=user_data['github_username'],
github_id=user_data.get('github_id'),
github_username=user_data['github_username'],
is_admin=True # SU user is always admin
)
async def get_token_provider(self, request: Request) -> TokenProvider:
user_context = await self.authenticate(request)
return SingleUserTokenProvider(user_context)
def get_login_url(self) -> Optional[str]:
if not self.requires_auth():
return None
return f"/api/auth/github/login"
```
#### 3. Multi User Strategy (Custom Build Extension Point)
```python
# openhands/auth/strategies/multi_user_strategy.py
from typing import Optional
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
class MultiUserStrategy(AuthStrategy):
"""Multi-user strategy - extension point for custom builds"""
def get_name(self) -> str:
return "multi_user"
def requires_auth(self) -> bool:
return True
async def authenticate(self, request: Request) -> Optional[UserContext]:
# This would be implemented by custom builds/applications built on OH
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
async def get_token_provider(self, request: Request) -> TokenProvider:
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
def get_login_url(self) -> Optional[str]:
return "/api/auth/login"
```
### Integration Points
#### 1. Updated UserAuth
```python
# openhands/server/user_auth/strategy_user_auth.py
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.storage_namespace import StorageNamespace
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.settings.settings_store import SettingsStore
from openhands.storage.secrets.secrets_store import SecretsStore
class StrategyUserAuth(UserAuth):
"""UserAuth implementation using AuthStrategy pattern"""
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
self.strategy = strategy
self.user_context = user_context
self._storage_namespace = StorageNamespace(
user_context.storage_namespace if user_context else None
)
async def get_user_id(self) -> str | None:
return self.user_context.user_id if self.user_context else None
async def get_user_email(self) -> str | None:
return self.user_context.email if self.user_context else None
# ... other methods using storage_namespace
```
#### 2. FastAPI Dependencies
```python
# openhands/server/dependencies/auth.py
from fastapi import Depends, Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
from openhands.server.shared import get_auth_strategy
async def get_current_user(
request: Request,
strategy: AuthStrategy = Depends(get_auth_strategy)
) -> Optional[UserContext]:
"""Get current user context"""
return await strategy.authenticate(request)
async def get_token_provider(
request: Request,
strategy: AuthStrategy = Depends(get_auth_strategy)
) -> TokenProvider:
"""Get token provider for current request"""
return await strategy.get_token_provider(request)
async def require_auth(
user: Optional[UserContext] = Depends(get_current_user)
) -> UserContext:
"""Require authentication"""
if not user:
raise HTTPException(401, "Authentication required")
return user
```
#### 3. Updated Routes
```python
# openhands/server/routes/git.py (AFTER)
from fastapi import APIRouter, Depends
from openhands.auth.token_provider import TokenProvider
from openhands.auth.user_context import UserContext
from openhands.server.dependencies.auth import get_token_provider, get_current_user
from openhands.integrations.provider import ProviderHandler
app = APIRouter(prefix='/api/user')
@app.get('/repositories')
async def get_user_repositories(
sort: str = "pushed",
selected_provider: ProviderType | None = None,
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user)
):
"""Get user repositories - no provider_tokens parameter!"""
client = ProviderHandler(token_provider=token_provider)
return await client.get_repositories(sort, selected_provider)
```
## Before/After Code Comparison
### Before: Current Implementation
```python
# BEFORE: openhands/server/routes/git.py
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = Query(default='pushed'),
selected_provider: ProviderType | None = Query(default=None),
page: int | None = Query(default=None),
per_page: int | None = Query(default=None),
installation_id: str | None = Query(default=None),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# ... complex logic
```
```python
# BEFORE: openhands/storage/locations.py
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
if user_id:
return f'users/{user_id}/conversations/{sid}/'
else:
return f'sessions/{sid}/'
```
```python
# BEFORE: openhands/server/user_auth/default_user_auth.py
class DefaultUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return None # Always None - no multi-tenancy support
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
user_secrets = await self.get_user_secrets()
if user_secrets is None:
return None
return user_secrets.provider_tokens
```
### After: Proposed Implementation
```python
# AFTER: openhands/server/routes/git.py
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = Query(default='pushed'),
selected_provider: ProviderType | None = Query(default=None),
page: int | None = Query(default=None),
per_page: int | None = Query(default=None),
installation_id: str | None = Query(default=None),
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user),
):
client = ProviderHandler(token_provider=token_provider)
return await client.get_repositories(
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
)
```
```python
# AFTER: openhands/auth/storage_namespace.py
@dataclass(frozen=True)
class StorageNamespace:
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
```
```python
# AFTER: openhands/server/user_auth/strategy_user_auth.py
class StrategyUserAuth(UserAuth):
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
self.strategy = strategy
self.user_context = user_context
self.storage_namespace = StorageNamespace(
user_context.storage_namespace if user_context else None
)
async def get_user_id(self) -> str | None:
return self.user_context.user_id if self.user_context else None
```
## Configuration
### Environment Variables
```bash
# Authentication Strategy
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
# Single User Mode Settings
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
# Multi User Mode (custom build extension point)
OH_MU_ADMIN_USERNAME=admin_user
```
### Configuration Modes
#### 1. None Mode (Current Default)
```bash
OH_AUTH_STRATEGY=none
# No additional config needed
```
#### 2. Single User - No Auth
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
```
#### 3. Single User - GitHub Auth
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=your_username
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
```
## Implementation Benefits
### 1. Clean Separation of Concerns
- Auth logic isolated in strategy classes
- Business logic doesn't need to know about user_id
- Clear boundaries between auth and core functionality
### 2. Reduced Complexity
- Eliminates 7 redundant `if user_id` guards
- Removes provider_tokens dependency injection
- Simplifies method signatures throughout codebase
### 3. Forward Compatibility
- custom builds can extend with custom strategies
- Token refresh/rotation support built-in
- Multi-tenancy ready without core changes
### 4. Security Improvements
- Tokens never exposed in route parameters
- Centralized token management
- Immutable user context prevents tampering
### 5. Developer Experience
- Clear configuration options
- Easy mode switching
- Consistent patterns across codebase
## Migration Strategy
### Phase 1: Foundation
1. Introduce auth strategy interfaces
2. Add UserContext and StorageNamespace
3. Create TokenProvider abstraction
4. Update core dependencies
### Phase 2: Strategy Implementation
1. Implement NoneStrategy (backward compatible)
2. Implement SingleUserStrategy
3. Add configuration support
4. Update UserAuth integration
### Phase 3: Route Migration
1. Update FastAPI dependencies
2. Remove provider_tokens dependency injection
3. Update ProviderHandler integration
4. Clean up redundant if-guards
### Phase 4: Storage Migration
1. Replace storage path helpers
2. Update conversation managers
3. Migrate event stores
4. Clean up legacy code
## Testing Strategy
### Unit Tests
- Strategy implementations
- UserContext immutability
- StorageNamespace path generation
- TokenProvider implementations
### Integration Tests
- End-to-end auth flows
- Route authentication
- Storage partitioning
- Configuration switching
### Migration Tests
- Backward compatibility
- Data migration paths
- Configuration validation
## Future Extensions
### custom builds Integration Points
```python
# custom builds can provide their own strategies
class custom buildsMultiUserStrategy(AuthStrategy):
async def authenticate(self, request: Request) -> Optional[UserContext]:
# Custom custom builds authentication logic
pass
async def get_token_provider(self, request: Request) -> TokenProvider:
# custom builds token refresh/rotation
return CustomBuildTokenProvider(request)
```
### Additional Auth Methods
- SAML/OIDC strategies
- API key authentication
- Custom JWT providers
- Enterprise SSO integration
## Architecture Diagrams
### 1. Overall Auth System Architecture
```mermaid
graph TB
subgraph "FastAPI Application"
Routes[API Routes]
Deps[FastAPI Dependencies]
end
subgraph "Auth Layer"
AuthStrategy[AuthStrategy Interface]
NoneStrategy[NoneStrategy]
SUStrategy[SingleUserStrategy]
MUStrategy[MultiUserStrategy]
end
subgraph "Core Abstractions"
UserContext[UserContext]
TokenProvider[TokenProvider Interface]
StorageNamespace[StorageNamespace]
end
subgraph "Token Providers"
DefaultTP[DefaultTokenProvider]
SingleUserTP[SingleUserTokenProvider]
custom buildsTP[CustomBuildTokenProvider]
end
subgraph "Storage Layer"
SecretsStore[SecretsStore]
SettingsStore[SettingsStore]
ConversationStore[ConversationStore]
end
Routes --> Deps
Deps --> AuthStrategy
AuthStrategy --> UserContext
AuthStrategy --> TokenProvider
UserContext --> StorageNamespace
NoneStrategy --> DefaultTP
SUStrategy --> SingleUserTP
MUStrategy --> custom buildsTP
TokenProvider --> SecretsStore
StorageNamespace --> ConversationStore
StorageNamespace --> SettingsStore
```
### 2. Authentication Flow - None Strategy
```mermaid
sequenceDiagram
participant Client
participant Route
participant NoneStrategy
participant DefaultTP
participant SecretsStore
Client->>Route: API Request
Route->>NoneStrategy: authenticate(request)
NoneStrategy->>Route: None (no user context)
Route->>NoneStrategy: get_token_provider(request)
NoneStrategy->>DefaultTP: create()
DefaultTP->>SecretsStore: load secrets.json
SecretsStore->>DefaultTP: provider tokens
DefaultTP->>Route: token provider
Route->>Client: API Response
```
### 3. Authentication Flow - Single User Strategy (No Auth)
```mermaid
sequenceDiagram
participant Client
participant Route
participant SUStrategy
participant UserContext
participant SingleUserTP
participant SecretsStore
Client->>Route: API Request
Route->>SUStrategy: authenticate(request)
SUStrategy->>UserContext: create virtual user (local)
UserContext->>SUStrategy: user context
SUStrategy->>Route: UserContext(user_id="local")
Route->>SUStrategy: get_token_provider(request)
SUStrategy->>SingleUserTP: create(user_context)
SingleUserTP->>SecretsStore: load user secrets
SecretsStore->>SingleUserTP: provider tokens
SingleUserTP->>Route: token provider
Route->>Client: API Response
```
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
```mermaid
sequenceDiagram
participant Client
participant Route
participant SUStrategy
participant GitHub
participant UserContext
participant SingleUserTP
participant Database
Client->>Route: API Request with JWT cookie
Route->>SUStrategy: authenticate(request)
SUStrategy->>SUStrategy: extract JWT token
SUStrategy->>SUStrategy: validate JWT
SUStrategy->>SUStrategy: check allowed user
SUStrategy->>UserContext: create from JWT data
UserContext->>SUStrategy: user context
SUStrategy->>Route: UserContext
Route->>SUStrategy: get_token_provider(request)
SUStrategy->>SingleUserTP: create(user_context)
SingleUserTP->>Database: load encrypted tokens
Database->>SingleUserTP: encrypted provider tokens
SingleUserTP->>Route: token provider
Route->>Client: API Response
```
### 5. GitHub OAuth Flow - Single User Strategy
```mermaid
sequenceDiagram
participant Client
participant AuthRoute
participant GitHub
participant SUStrategy
participant Database
participant UserContext
Client->>AuthRoute: GET /auth/login
AuthRoute->>Client: GitHub OAuth URL
Client->>GitHub: OAuth authorization
GitHub->>AuthRoute: GET /auth/callback?code=xxx
AuthRoute->>GitHub: exchange code for token
GitHub->>AuthRoute: access token + user info
AuthRoute->>SUStrategy: validate user allowed
SUStrategy->>AuthRoute: user authorized
AuthRoute->>Database: create/update user record
Database->>AuthRoute: user saved
AuthRoute->>AuthRoute: create JWT token
AuthRoute->>Client: Set JWT cookie + redirect
```
### 6. Storage Namespace Architecture
```mermaid
graph TB
subgraph "User Context"
UC[UserContext]
UC --> SN[StorageNamespace]
end
subgraph "Storage Paths"
SN --> ConvDir[get_conversation_dir]
SN --> EventsDir[get_events_dir]
SN --> MetaFile[get_metadata_file]
SN --> StateFile[get_state_file]
end
subgraph "Path Examples"
ConvDir --> NonePath[sessions/sid/]
ConvDir --> UserPath[users/user_id/conversations/sid/]
EventsDir --> NoneEvents[sessions/sid/events/]
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
end
subgraph "Strategy Impact"
NoneStrategy2[NoneStrategy] --> NonePath
SUStrategy2[SingleUserStrategy] --> UserPath
MUStrategy2[MultiUserStrategy] --> UserPath
end
```
### 7. Token Provider Architecture
```mermaid
graph TB
subgraph "Token Provider Interface"
TP[TokenProvider]
TP --> GetToken[get_token(provider)]
TP --> GetAllTokens[get_all_tokens()]
end
subgraph "Implementations"
DefaultTP2[DefaultTokenProvider]
SingleUserTP2[SingleUserTokenProvider]
custom buildsTP2[CustomBuildTokenProvider]
end
subgraph "Token Sources"
SecretsJSON[secrets.json]
UserDB[User Database]
Custom Build API[custom builds Token API]
end
subgraph "Provider Integration"
ProviderHandler[ProviderHandler]
GitHubService[GitHubService]
GitLabService[GitLabService]
BitBucketService[BitBucketService]
end
DefaultTP2 --> SecretsJSON
SingleUserTP2 --> UserDB
custom buildsTP2 --> Custom Build API
TP --> ProviderHandler
ProviderHandler --> GitHubService
ProviderHandler --> GitLabService
ProviderHandler --> BitBucketService
```
### 8. Configuration-Driven Strategy Selection
```mermaid
graph TB
subgraph "Configuration"
Config[OH_AUTH_STRATEGY]
Config --> None[none]
Config --> SU[single_user]
Config --> MU[multi_user]
end
subgraph "Strategy Factory"
Factory[AuthStrategyFactory]
Factory --> CreateNone[create NoneStrategy]
Factory --> CreateSU[create SingleUserStrategy]
Factory --> CreateMU[create MultiUserStrategy]
end
subgraph "Additional Config"
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
end
None --> CreateNone
SU --> CreateSU
MU --> CreateMU
CreateSU --> SUConfig
CreateMU --> MUConfig
```
## Conclusion
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
1. **Maintains backward compatibility** with the current "None" mode
2. **Enables Single User mode** with optional GitHub OAuth
3. **Provides extension points** for custom builds with multi-user implementations
4. **Cleans up the codebase** by removing scattered user_id threading
5. **Improves security** by centralizing token management
6. **Simplifies development** with clear abstractions and patterns
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
@@ -0,0 +1,860 @@
---
title: AuthSystem Design - Complete Specification
---
# OpenHands AuthSystem Design
## Executive Summary
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
## Problem Statement
### Current Issues
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
- Storage partitioning (`users/{user_id}/` paths)
- Conversation/session scoping
- API route dependencies
- Provider token resolution
- Data model fields
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
### Requirements from GitHub Issues
From **Issue #10751** (user_id audit):
- Support None, SU, and MU modes
- Introduce `UserContext` and `StorageNamespace` abstractions
- Remove redundant `if user_id` guards (7 identified)
- Clean up storage path helpers
From **Issue #10730** (token provider):
- Remove `provider_tokens` dependency injection
- Introduce `TokenProvider` boundary abstraction
- Support backend-only credential resolution
- Enable custom builds with token refresh/rotation patterns
## Solution Architecture
### Core Components
#### 1. AuthStrategy Interface
```python
# openhands/auth/strategies/base.py
from abc import ABC, abstractmethod
from typing import Optional
from fastapi import Request
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
class AuthStrategy(ABC):
"""Base class for authentication strategies"""
@abstractmethod
def get_name(self) -> str:
"""Return strategy name for logging/debugging"""
@abstractmethod
def requires_auth(self) -> bool:
"""Whether this strategy requires user authentication"""
@abstractmethod
async def authenticate(self, request: Request) -> Optional[UserContext]:
"""Authenticate request and return UserContext or None"""
@abstractmethod
async def get_token_provider(self, request: Request) -> TokenProvider:
"""Get token provider for this request"""
@abstractmethod
def get_login_url(self) -> Optional[str]:
"""Get login URL for frontend, None if no auth required"""
```
#### 2. UserContext
```python
# openhands/auth/user_context.py
from dataclasses import dataclass
from typing import Optional
from datetime import datetime
@dataclass(frozen=True)
class UserContext:
"""Immutable user context for authenticated requests"""
user_id: str
email: Optional[str] = None
username: Optional[str] = None
github_id: Optional[int] = None
github_username: Optional[str] = None
is_admin: bool = False
created_at: Optional[datetime] = None
last_login: Optional[datetime] = None
@property
def storage_namespace(self) -> str:
"""Get storage namespace for this user"""
return self.user_id
```
#### 3. TokenProvider Interface
```python
# openhands/auth/token_provider.py
from abc import ABC, abstractmethod
from typing import Optional, Mapping
from openhands.integrations.service_types import ProviderType
from openhands.integrations.provider import ProviderToken
class TokenProvider(ABC):
"""Abstract token provider for git integrations"""
@abstractmethod
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
"""Get token for specific provider"""
@abstractmethod
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
"""Get all available provider tokens"""
```
#### 4. StorageNamespace
```python
# openhands/auth/storage_namespace.py
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class StorageNamespace:
"""Encapsulates storage path logic for user data"""
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
def get_conversation_events_dir(self, sid: str) -> str:
return f'{self.get_conversation_dir(sid)}events/'
def get_conversation_metadata_filename(self, sid: str) -> str:
return f'{self.get_conversation_dir(sid)}metadata.json'
# ... other path methods
```
### Authentication Strategies
#### 1. None Strategy (Current Behavior)
```python
# openhands/auth/strategies/none_strategy.py
from typing import Optional
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
class NoneStrategy(AuthStrategy):
"""No authentication - current OpenHands behavior"""
def get_name(self) -> str:
return "none"
def requires_auth(self) -> bool:
return False
async def authenticate(self, request: Request) -> Optional[UserContext]:
return None # No user context
async def get_token_provider(self, request: Request) -> TokenProvider:
return DefaultTokenProvider() # Uses secrets.json
def get_login_url(self) -> Optional[str]:
return None
```
#### 2. Single User Strategy
```python
# openhands/auth/strategies/single_user_strategy.py
from typing import Optional
from fastapi import Request, HTTPException
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
from openhands.server.shared import server_config
class SingleUserStrategy(AuthStrategy):
"""Single user with GitHub OAuth"""
def get_name(self) -> str:
return "single_user"
def requires_auth(self) -> bool:
return server_config.enable_su_auth
async def authenticate(self, request: Request) -> Optional[UserContext]:
if not self.requires_auth():
# SU mode without auth - create virtual user
return UserContext(
user_id="local",
username="local_user",
is_admin=True
)
# Extract JWT token from cookie/header
token = self._extract_token(request)
if not token:
return None
# Validate JWT and extract user info
user_data = self._validate_jwt(token)
if not user_data:
return None
# Verify user is allowed (if configured)
if (server_config.su_github_username and
user_data.get('github_username') != server_config.su_github_username):
raise HTTPException(403, "Access denied")
return UserContext(
user_id=user_data['github_username'],
email=user_data.get('email'),
username=user_data['github_username'],
github_id=user_data.get('github_id'),
github_username=user_data['github_username'],
is_admin=True # SU user is always admin
)
async def get_token_provider(self, request: Request) -> TokenProvider:
user_context = await self.authenticate(request)
return SingleUserTokenProvider(user_context)
def get_login_url(self) -> Optional[str]:
if not self.requires_auth():
return None
return f"/api/auth/github/login"
```
#### 3. Multi User Strategy (Custom Build Extension Point)
```python
# openhands/auth/strategies/multi_user_strategy.py
from typing import Optional
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
class MultiUserStrategy(AuthStrategy):
"""Multi-user strategy - extension point for custom builds"""
def get_name(self) -> str:
return "multi_user"
def requires_auth(self) -> bool:
return True
async def authenticate(self, request: Request) -> Optional[UserContext]:
# This would be implemented by custom builds/applications built on OH
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
async def get_token_provider(self, request: Request) -> TokenProvider:
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
def get_login_url(self) -> Optional[str]:
return "/api/auth/login"
```
### Integration Points
#### 1. Updated UserAuth
```python
# openhands/server/user_auth/strategy_user_auth.py
from fastapi import Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.storage_namespace import StorageNamespace
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.settings.settings_store import SettingsStore
from openhands.storage.secrets.secrets_store import SecretsStore
class StrategyUserAuth(UserAuth):
"""UserAuth implementation using AuthStrategy pattern"""
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
self.strategy = strategy
self.user_context = user_context
self._storage_namespace = StorageNamespace(
user_context.storage_namespace if user_context else None
)
async def get_user_id(self) -> str | None:
return self.user_context.user_id if self.user_context else None
async def get_user_email(self) -> str | None:
return self.user_context.email if self.user_context else None
# ... other methods using storage_namespace
```
#### 2. FastAPI Dependencies
```python
# openhands/server/dependencies/auth.py
from fastapi import Depends, Request
from openhands.auth.strategies.base import AuthStrategy
from openhands.auth.user_context import UserContext
from openhands.auth.token_provider import TokenProvider
from openhands.server.shared import get_auth_strategy
async def get_current_user(
request: Request,
strategy: AuthStrategy = Depends(get_auth_strategy)
) -> Optional[UserContext]:
"""Get current user context"""
return await strategy.authenticate(request)
async def get_token_provider(
request: Request,
strategy: AuthStrategy = Depends(get_auth_strategy)
) -> TokenProvider:
"""Get token provider for current request"""
return await strategy.get_token_provider(request)
async def require_auth(
user: Optional[UserContext] = Depends(get_current_user)
) -> UserContext:
"""Require authentication"""
if not user:
raise HTTPException(401, "Authentication required")
return user
```
#### 3. Updated Routes
```python
# openhands/server/routes/git.py (AFTER)
from fastapi import APIRouter, Depends
from openhands.auth.token_provider import TokenProvider
from openhands.auth.user_context import UserContext
from openhands.server.dependencies.auth import get_token_provider, get_current_user
from openhands.integrations.provider import ProviderHandler
app = APIRouter(prefix='/api/user')
@app.get('/repositories')
async def get_user_repositories(
sort: str = "pushed",
selected_provider: ProviderType | None = None,
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user)
):
"""Get user repositories - no provider_tokens parameter!"""
client = ProviderHandler(token_provider=token_provider)
return await client.get_repositories(sort, selected_provider)
```
## Before/After Code Comparison
### Before: Current Implementation
```python
# BEFORE: openhands/server/routes/git.py
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = Query(default='pushed'),
selected_provider: ProviderType | None = Query(default=None),
page: int | None = Query(default=None),
per_page: int | None = Query(default=None),
installation_id: str | None = Query(default=None),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# ... complex logic
```
```python
# BEFORE: openhands/storage/locations.py
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
if user_id:
return f'users/{user_id}/conversations/{sid}/'
else:
return f'sessions/{sid}/'
```
```python
# BEFORE: openhands/server/user_auth/default_user_auth.py
class DefaultUserAuth(UserAuth):
async def get_user_id(self) -> str | None:
return None # Always None - no multi-tenancy support
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
user_secrets = await self.get_user_secrets()
if user_secrets is None:
return None
return user_secrets.provider_tokens
```
### After: Proposed Implementation
```python
# AFTER: openhands/server/routes/git.py
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = Query(default='pushed'),
selected_provider: ProviderType | None = Query(default=None),
page: int | None = Query(default=None),
per_page: int | None = Query(default=None),
installation_id: str | None = Query(default=None),
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user),
):
client = ProviderHandler(token_provider=token_provider)
return await client.get_repositories(
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
)
```
```python
# AFTER: openhands/auth/storage_namespace.py
@dataclass(frozen=True)
class StorageNamespace:
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
```
```python
# AFTER: openhands/server/user_auth/strategy_user_auth.py
class StrategyUserAuth(UserAuth):
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
self.strategy = strategy
self.user_context = user_context
self.storage_namespace = StorageNamespace(
user_context.storage_namespace if user_context else None
)
async def get_user_id(self) -> str | None:
return self.user_context.user_id if self.user_context else None
```
## Configuration
### Environment Variables
```bash
# Authentication Strategy
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
# Single User Mode Settings
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
# Multi User Mode (custom build extension point)
OH_MU_ADMIN_USERNAME=admin_user
```
### Configuration Modes
#### 1. None Mode (Current Default)
```bash
OH_AUTH_STRATEGY=none
# No additional config needed
```
#### 2. Single User - No Auth
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
```
#### 3. Single User - GitHub Auth
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=your_username
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
```
## Implementation Benefits
### 1. Clean Separation of Concerns
- Auth logic isolated in strategy classes
- Business logic doesn't need to know about user_id
- Clear boundaries between auth and core functionality
### 2. Reduced Complexity
- Eliminates 7 redundant `if user_id` guards
- Removes provider_tokens dependency injection
- Simplifies method signatures throughout codebase
### 3. Forward Compatibility
- custom builds can extend with custom strategies
- Token refresh/rotation support built-in
- Multi-tenancy ready without core changes
### 4. Security Improvements
- Tokens never exposed in route parameters
- Centralized token management
- Immutable user context prevents tampering
### 5. Developer Experience
- Clear configuration options
- Easy mode switching
- Consistent patterns across codebase
## Migration Strategy
### Phase 1: Foundation
1. Introduce auth strategy interfaces
2. Add UserContext and StorageNamespace
3. Create TokenProvider abstraction
4. Update core dependencies
### Phase 2: Strategy Implementation
1. Implement NoneStrategy (backward compatible)
2. Implement SingleUserStrategy
3. Add configuration support
4. Update UserAuth integration
### Phase 3: Route Migration
1. Update FastAPI dependencies
2. Remove provider_tokens dependency injection
3. Update ProviderHandler integration
4. Clean up redundant if-guards
### Phase 4: Storage Migration
1. Replace storage path helpers
2. Update conversation managers
3. Migrate event stores
4. Clean up legacy code
## Testing Strategy
### Unit Tests
- Strategy implementations
- UserContext immutability
- StorageNamespace path generation
- TokenProvider implementations
### Integration Tests
- End-to-end auth flows
- Route authentication
- Storage partitioning
- Configuration switching
### Migration Tests
- Backward compatibility
- Data migration paths
- Configuration validation
## Future Extensions
### custom builds Integration Points
```python
# custom builds can provide their own strategies
class custom buildsMultiUserStrategy(AuthStrategy):
async def authenticate(self, request: Request) -> Optional[UserContext]:
# Custom custom builds authentication logic
pass
async def get_token_provider(self, request: Request) -> TokenProvider:
# custom builds token refresh/rotation
return CustomBuildTokenProvider(request)
```
### Additional Auth Methods
- SAML/OIDC strategies
- API key authentication
- Custom JWT providers
- Enterprise SSO integration
## Architecture Diagrams
### 1. Overall Auth System Architecture
```mermaid
graph TB
subgraph "FastAPI Application"
Routes[API Routes]
Deps[FastAPI Dependencies]
end
subgraph "Auth Layer"
AuthStrategy[AuthStrategy Interface]
NoneStrategy[NoneStrategy]
SUStrategy[SingleUserStrategy]
MUStrategy[MultiUserStrategy]
end
subgraph "Core Abstractions"
UserContext[UserContext]
TokenProvider[TokenProvider Interface]
StorageNamespace[StorageNamespace]
end
subgraph "Token Providers"
DefaultTP[DefaultTokenProvider]
SingleUserTP[SingleUserTokenProvider]
custom buildsTP[CustomBuildTokenProvider]
end
subgraph "Storage Layer"
SecretsStore[SecretsStore]
SettingsStore[SettingsStore]
ConversationStore[ConversationStore]
end
Routes --> Deps
Deps --> AuthStrategy
AuthStrategy --> UserContext
AuthStrategy --> TokenProvider
UserContext --> StorageNamespace
NoneStrategy --> DefaultTP
SUStrategy --> SingleUserTP
MUStrategy --> custom buildsTP
TokenProvider --> SecretsStore
StorageNamespace --> ConversationStore
StorageNamespace --> SettingsStore
```
### 2. Authentication Flow - None Strategy
```mermaid
sequenceDiagram
participant Client
participant Route
participant NoneStrategy
participant DefaultTP
participant SecretsStore
Client->>Route: API Request
Route->>NoneStrategy: authenticate(request)
NoneStrategy->>Route: None (no user context)
Route->>NoneStrategy: get_token_provider(request)
NoneStrategy->>DefaultTP: create()
DefaultTP->>SecretsStore: load secrets.json
SecretsStore->>DefaultTP: provider tokens
DefaultTP->>Route: token provider
Route->>Client: API Response
```
### 3. Authentication Flow - Single User Strategy (No Auth)
```mermaid
sequenceDiagram
participant Client
participant Route
participant SUStrategy
participant UserContext
participant SingleUserTP
participant SecretsStore
Client->>Route: API Request
Route->>SUStrategy: authenticate(request)
SUStrategy->>UserContext: create virtual user (local)
UserContext->>SUStrategy: user context
SUStrategy->>Route: UserContext(user_id="local")
Route->>SUStrategy: get_token_provider(request)
SUStrategy->>SingleUserTP: create(user_context)
SingleUserTP->>SecretsStore: load user secrets
SecretsStore->>SingleUserTP: provider tokens
SingleUserTP->>Route: token provider
Route->>Client: API Response
```
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
```mermaid
sequenceDiagram
participant Client
participant Route
participant SUStrategy
participant GitHub
participant UserContext
participant SingleUserTP
participant Database
Client->>Route: API Request with JWT cookie
Route->>SUStrategy: authenticate(request)
SUStrategy->>SUStrategy: extract JWT token
SUStrategy->>SUStrategy: validate JWT
SUStrategy->>SUStrategy: check allowed user
SUStrategy->>UserContext: create from JWT data
UserContext->>SUStrategy: user context
SUStrategy->>Route: UserContext
Route->>SUStrategy: get_token_provider(request)
SUStrategy->>SingleUserTP: create(user_context)
SingleUserTP->>Database: load encrypted tokens
Database->>SingleUserTP: encrypted provider tokens
SingleUserTP->>Route: token provider
Route->>Client: API Response
```
### 5. GitHub OAuth Flow - Single User Strategy
```mermaid
sequenceDiagram
participant Client
participant AuthRoute
participant GitHub
participant SUStrategy
participant Database
participant UserContext
Client->>AuthRoute: GET /auth/login
AuthRoute->>Client: GitHub OAuth URL
Client->>GitHub: OAuth authorization
GitHub->>AuthRoute: GET /auth/callback?code=xxx
AuthRoute->>GitHub: exchange code for token
GitHub->>AuthRoute: access token + user info
AuthRoute->>SUStrategy: validate user allowed
SUStrategy->>AuthRoute: user authorized
AuthRoute->>Database: create/update user record
Database->>AuthRoute: user saved
AuthRoute->>AuthRoute: create JWT token
AuthRoute->>Client: Set JWT cookie + redirect
```
### 6. Storage Namespace Architecture
```mermaid
graph TB
subgraph "User Context"
UC[UserContext]
UC --> SN[StorageNamespace]
end
subgraph "Storage Paths"
SN --> ConvDir[get_conversation_dir]
SN --> EventsDir[get_events_dir]
SN --> MetaFile[get_metadata_file]
SN --> StateFile[get_state_file]
end
subgraph "Path Examples"
ConvDir --> NonePath[sessions/sid/]
ConvDir --> UserPath[users/user_id/conversations/sid/]
EventsDir --> NoneEvents[sessions/sid/events/]
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
end
subgraph "Strategy Impact"
NoneStrategy2[NoneStrategy] --> NonePath
SUStrategy2[SingleUserStrategy] --> UserPath
MUStrategy2[MultiUserStrategy] --> UserPath
end
```
### 7. Token Provider Architecture
```mermaid
graph TB
subgraph "Token Provider Interface"
TP[TokenProvider]
TP --> GetToken[get_token(provider)]
TP --> GetAllTokens[get_all_tokens()]
end
subgraph "Implementations"
DefaultTP2[DefaultTokenProvider]
SingleUserTP2[SingleUserTokenProvider]
custom buildsTP2[CustomBuildTokenProvider]
end
subgraph "Token Sources"
SecretsJSON[secrets.json]
UserDB[User Database]
Custom Build API[custom builds Token API]
end
subgraph "Provider Integration"
ProviderHandler[ProviderHandler]
GitHubService[GitHubService]
GitLabService[GitLabService]
BitBucketService[BitBucketService]
end
DefaultTP2 --> SecretsJSON
SingleUserTP2 --> UserDB
custom buildsTP2 --> Custom Build API
TP --> ProviderHandler
ProviderHandler --> GitHubService
ProviderHandler --> GitLabService
ProviderHandler --> BitBucketService
```
### 8. Configuration-Driven Strategy Selection
```mermaid
graph TB
subgraph "Configuration"
Config[OH_AUTH_STRATEGY]
Config --> None[none]
Config --> SU[single_user]
Config --> MU[multi_user]
end
subgraph "Strategy Factory"
Factory[AuthStrategyFactory]
Factory --> CreateNone[create NoneStrategy]
Factory --> CreateSU[create SingleUserStrategy]
Factory --> CreateMU[create MultiUserStrategy]
end
subgraph "Additional Config"
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
end
None --> CreateNone
SU --> CreateSU
MU --> CreateMU
CreateSU --> SUConfig
CreateMU --> MUConfig
```
## Conclusion
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
1. **Maintains backward compatibility** with the current "None" mode
2. **Enables Single User mode** with optional GitHub OAuth
3. **Provides extension points** for custom builds with multi-user implementations
4. **Cleans up the codebase** by removing scattered user_id threading
5. **Improves security** by centralizing token management
6. **Simplifies development** with clear abstractions and patterns
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
@@ -0,0 +1,210 @@
# OpenHands AuthSystem Design - Executive Summary
## Goal
Design a flexible authentication system for OpenHands that supports three strategies:
- **None**: Current behavior (no auth, optional GitHub token)
- **SU (Single User)**: GitHub OAuth for personal use
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
## Current Problems
- 339+ `user_id` occurrences scattered across 68 files
- No auth strategy abstraction
- `provider_tokens` dependency injection complexity
- No single-user GitHub OAuth support
- Mixed auth/business logic concerns
## Solution Architecture
### Core Components
1. **AuthStrategy Interface** - Pluggable auth strategies
2. **UserContext** - Immutable user data container
3. **TokenProvider** - Centralized token management
4. **StorageNamespace** - Clean storage path abstraction
### Auth Strategies
```python
# None Strategy (current behavior)
OH_AUTH_STRATEGY=none
# Single User - No Auth (virtual user)
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
# Single User - GitHub OAuth
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=your_username
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
```
## 🔄 Key Changes
### Before (Current)
```python
# Route with complex dependencies
async def get_repositories(
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
user_id: str | None = Depends(get_user_id),
):
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_id=user_id,
)
# Scattered path logic
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
if user_id:
return f'users/{user_id}/conversations/{sid}/'
else:
return f'sessions/{sid}/'
```
### After (Proposed)
```python
# Clean route signature
async def get_repositories(
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user),
):
client = ProviderHandler(token_provider=token_provider)
# Encapsulated storage logic
@dataclass(frozen=True)
class StorageNamespace:
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
```
## Architectural Benefits
### Codebase Cleanup
- Removes 7 redundant `if user_id` guards across the codebase
- Eliminates `provider_tokens` dependency injection complexity
- Reduces method signature complexity throughout the system
- Centralizes storage path logic in dedicated abstractions
### Extensibility
- Strategy pattern enables custom build extension points
- Token refresh/rotation patterns built-in
- Multi-tenancy ready without core changes
- Additional auth methods can be added without refactoring
### Code Organization
- Clear separation of auth and business logic
- Consistent patterns across all authentication modes
- Centralized token and credential management
- Immutable user context prevents state corruption
## Implementation Plan
### Phase 1: Foundation
- [ ] Auth strategy interfaces
- [ ] UserContext & StorageNamespace
- [ ] TokenProvider abstraction
- [ ] Core dependencies
### Phase 2: Strategies
- [ ] NoneStrategy (backward compatible)
- [ ] SingleUserStrategy
- [ ] Configuration support
- [ ] UserAuth integration
### Phase 3: Routes
- [ ] Update FastAPI dependencies
- [ ] Remove provider_tokens
- [ ] Update ProviderHandler
- [ ] Clean redundant guards
### Phase 4: Storage
- [ ] Replace path helpers
- [ ] Update conversation managers
- [ ] Migrate event stores
- [ ] Legacy cleanup
## Architecture Highlights
### Strategy Pattern
```python
class AuthStrategy(ABC):
@abstractmethod
async def authenticate(self, request: Request) -> Optional[UserContext]:
pass
@abstractmethod
async def get_token_provider(self, request: Request) -> TokenProvider:
pass
```
### Immutable User Context
```python
@dataclass(frozen=True)
class UserContext:
user_id: str
email: Optional[str] = None
username: Optional[str] = None
is_admin: bool = False
```
### Token Provider Interface
```python
class TokenProvider(ABC):
@abstractmethod
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
pass
```
## 🔧 Configuration Examples
### Current Default (None)
```bash
# No configuration needed - maintains current behavior
```
### Personal Use (SU without auth)
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
# Creates virtual "local" user, uses secrets.json
```
### Personal Use (SU with GitHub)
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=myusername
OH_GITHUB_CLIENT_ID=abc123
OH_GITHUB_CLIENT_SECRET=secret456
# Requires GitHub OAuth, restricts to specific user
```
## Implementation Readiness
### Backward Compatibility
- None strategy maintains exact current behavior
- No breaking changes for existing users
- Gradual migration path available
### Code Quality Improvements
- Reduces complexity from 339 to ~50 user_id references
- Introduces clear abstractions and boundaries
- Enables better testing and maintainability
### Extensibility Foundation
- Custom builds can add authentication strategies
- Token refresh/rotation patterns built-in
- Multi-tenancy foundation without core changes
## Summary
This design provides a clean authentication architecture for OpenHands with three key outcomes:
1. **Maintains simplicity** - Current users see no changes
2. **Enables extension** - Custom builds can add authentication features
3. **Improves codebase** - Reduces scattered auth logic and complexity
The architecture is well-defined with a clear migration path.
@@ -0,0 +1,214 @@
---
title: AuthSystem Design - Executive Summary
---
# OpenHands AuthSystem Design - Executive Summary
## Goal
Design a flexible authentication system for OpenHands that supports three strategies:
- **None**: Current behavior (no auth, optional GitHub token)
- **SU (Single User)**: GitHub OAuth for personal use
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
## Current Problems
- 339+ `user_id` occurrences scattered across 68 files
- No auth strategy abstraction
- `provider_tokens` dependency injection complexity
- No single-user GitHub OAuth support
- Mixed auth/business logic concerns
## Solution Architecture
### Core Components
1. **AuthStrategy Interface** - Pluggable auth strategies
2. **UserContext** - Immutable user data container
3. **TokenProvider** - Centralized token management
4. **StorageNamespace** - Clean storage path abstraction
### Auth Strategies
```python
# None Strategy (current behavior)
OH_AUTH_STRATEGY=none
# Single User - No Auth (virtual user)
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
# Single User - GitHub OAuth
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=your_username
OH_GITHUB_CLIENT_ID=your_client_id
OH_GITHUB_CLIENT_SECRET=your_client_secret
```
## 🔄 Key Changes
### Before (Current)
```python
# Route with complex dependencies
async def get_repositories(
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
user_id: str | None = Depends(get_user_id),
):
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_id=user_id,
)
# Scattered path logic
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
if user_id:
return f'users/{user_id}/conversations/{sid}/'
else:
return f'sessions/{sid}/'
```
### After (Proposed)
```python
# Clean route signature
async def get_repositories(
token_provider: TokenProvider = Depends(get_token_provider),
user: Optional[UserContext] = Depends(get_current_user),
):
client = ProviderHandler(token_provider=token_provider)
# Encapsulated storage logic
@dataclass(frozen=True)
class StorageNamespace:
namespace: Optional[str]
def get_conversation_dir(self, sid: str) -> str:
if self.namespace:
return f'users/{self.namespace}/conversations/{sid}/'
return f'sessions/{sid}/'
```
## Architectural Benefits
### Codebase Cleanup
- Removes 7 redundant `if user_id` guards across the codebase
- Eliminates `provider_tokens` dependency injection complexity
- Reduces method signature complexity throughout the system
- Centralizes storage path logic in dedicated abstractions
### Extensibility
- Strategy pattern enables custom build extension points
- Token refresh/rotation patterns built-in
- Multi-tenancy ready without core changes
- Additional auth methods can be added without refactoring
### Code Organization
- Clear separation of auth and business logic
- Consistent patterns across all authentication modes
- Centralized token and credential management
- Immutable user context prevents state corruption
## Implementation Plan
### Phase 1: Foundation
- [ ] Auth strategy interfaces
- [ ] UserContext & StorageNamespace
- [ ] TokenProvider abstraction
- [ ] Core dependencies
### Phase 2: Strategies
- [ ] NoneStrategy (backward compatible)
- [ ] SingleUserStrategy
- [ ] Configuration support
- [ ] UserAuth integration
### Phase 3: Routes
- [ ] Update FastAPI dependencies
- [ ] Remove provider_tokens
- [ ] Update ProviderHandler
- [ ] Clean redundant guards
### Phase 4: Storage
- [ ] Replace path helpers
- [ ] Update conversation managers
- [ ] Migrate event stores
- [ ] Legacy cleanup
## Architecture Highlights
### Strategy Pattern
```python
class AuthStrategy(ABC):
@abstractmethod
async def authenticate(self, request: Request) -> Optional[UserContext]:
pass
@abstractmethod
async def get_token_provider(self, request: Request) -> TokenProvider:
pass
```
### Immutable User Context
```python
@dataclass(frozen=True)
class UserContext:
user_id: str
email: Optional[str] = None
username: Optional[str] = None
is_admin: bool = False
```
### Token Provider Interface
```python
class TokenProvider(ABC):
@abstractmethod
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
pass
```
## 🔧 Configuration Examples
### Current Default (None)
```bash
# No configuration needed - maintains current behavior
```
### Personal Use (SU without auth)
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=false
# Creates virtual "local" user, uses secrets.json
```
### Personal Use (SU with GitHub)
```bash
OH_AUTH_STRATEGY=single_user
OH_ENABLE_SU_AUTH=true
OH_SU_GITHUB_USERNAME=myusername
OH_GITHUB_CLIENT_ID=abc123
OH_GITHUB_CLIENT_SECRET=secret456
# Requires GitHub OAuth, restricts to specific user
```
## Implementation Readiness
### Backward Compatibility
- None strategy maintains exact current behavior
- No breaking changes for existing users
- Gradual migration path available
### Code Quality Improvements
- Reduces complexity from 339 to ~50 user_id references
- Introduces clear abstractions and boundaries
- Enables better testing and maintainability
### Extensibility Foundation
- Custom builds can add authentication strategies
- Token refresh/rotation patterns built-in
- Multi-tenancy foundation without core changes
## Summary
This design provides a clean authentication architecture for OpenHands with three key outcomes:
1. **Maintains simplicity** - Current users see no changes
2. **Enables extension** - Custom builds can add authentication features
3. **Improves codebase** - Reduces scattered auth logic and complexity
The architecture is well-defined with a clear migration path.
-217
View File
@@ -1,217 +0,0 @@
"""Example of how an external repository can extend OpenHands.
This demonstrates the proper way for external repositories to build upon OpenHands
without relying on environment variables or global state. The external repo can:
1. Create its own FastAPI app with custom context
2. Add its own routes and middleware
3. Include OpenHands routes as needed
4. Override specific behaviors through dependency injection
This approach eliminates the need for environment variable configuration
and allows clean separation between OpenHands core and extensions.
"""
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from openhands.server.context.server_context import ServerContext
from openhands.server.factory import create_openhands_app
# Step 1: Create your custom ServerContext
class ExternalRepoContext(ServerContext):
"""Custom context for external repository with enterprise features."""
def __init__(self, tenant_id: str = 'default', user_id: Optional[str] = None):
super().__init__()
self.tenant_id = tenant_id
self.user_id = user_id
self._custom_config = None
def get_config(self):
"""Override config with tenant-specific settings."""
config = super().get_config()
# Add tenant-specific configuration
config.update({
'tenant_id': self.tenant_id,
'custom_storage_path': f'/data/tenants/{self.tenant_id}',
'custom_feature_flags': {
'enterprise_features': True,
'advanced_analytics': True,
}
})
return config
def get_server_config(self):
"""Override server config for enterprise deployment."""
server_config = super().get_server_config()
# Customize for enterprise
server_config.app_mode = 'ENTERPRISE' # Custom app mode
server_config.enable_billing = True
server_config.hide_llm_settings = False
return server_config
def get_file_store(self):
"""Use tenant-isolated file storage."""
# In a real implementation, this would return a tenant-aware file store
file_store = super().get_file_store()
# Customize file store for tenant isolation
return file_store
# Step 2: Create your custom lifespan (optional)
@asynccontextmanager
async def external_repo_lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Custom lifespan for external repo initialization."""
print("🚀 Starting external repo services...")
# Initialize your custom services here
# e.g., database connections, external API clients, etc.
yield
print("🛑 Shutting down external repo services...")
# Cleanup your custom services here
# Step 3: Create context factory for your needs
def create_external_context(tenant_id: str = 'default') -> ExternalRepoContext:
"""Factory function to create context instances."""
return ExternalRepoContext(tenant_id=tenant_id)
# Step 4: Create your FastAPI app with OpenHands integration
def create_external_app() -> FastAPI:
"""Create the external repository's FastAPI application."""
# Option A: Create OpenHands app with your custom context
openhands_app = create_openhands_app(
context_factory=lambda: create_external_context(),
include_oss_routes=False, # Skip OSS routes for enterprise
custom_lifespan=external_repo_lifespan,
title='My Enterprise Platform',
description='Enterprise platform built on OpenHands'
)
# Option B: Create your own app and mount OpenHands
main_app = FastAPI(
title='My Enterprise Platform',
description='Enterprise platform with OpenHands integration',
version='1.0.0'
)
# Add your custom routes
@main_app.get('/enterprise/status')
async def enterprise_status():
return {'status': 'running', 'mode': 'enterprise'}
@main_app.get('/enterprise/tenant/{tenant_id}/info')
async def tenant_info(
tenant_id: str,
request: Request,
# Use dependency injection to get context
context: ServerContext = Depends(lambda r: create_external_context(tenant_id))
):
config = context.get_config()
return {
'tenant_id': tenant_id,
'storage_path': config.get('custom_storage_path'),
'features': config.get('custom_feature_flags', {})
}
# Add custom middleware
@main_app.middleware('http')
async def tenant_middleware(request: Request, call_next):
# Extract tenant from header or path
tenant_id = request.headers.get('X-Tenant-ID', 'default')
request.state.tenant_id = tenant_id
response = await call_next(request)
response.headers['X-Tenant-ID'] = tenant_id
return response
# Mount OpenHands app at a subpath
main_app.mount('/openhands', openhands_app)
return main_app
# Step 5: Alternative approach - extend OpenHands app directly
def create_extended_openhands_app() -> FastAPI:
"""Alternative: extend OpenHands app directly with custom routes."""
app = create_openhands_app(
context_factory=lambda: create_external_context(),
custom_lifespan=external_repo_lifespan
)
# Add your routes to the OpenHands app
@app.get('/api/enterprise/dashboard')
async def enterprise_dashboard(
request: Request,
context: ServerContext = Depends(lambda r: create_external_context())
):
config = context.get_config()
return {
'dashboard_data': 'enterprise_metrics',
'tenant_features': config.get('custom_feature_flags', {})
}
return app
# Example usage in external repo's main.py
if __name__ == '__main__':
import uvicorn
# Choose your approach
app = create_external_app() # Full custom app with OpenHands mounted
# app = create_extended_openhands_app() # Extended OpenHands app
# Run the server
uvicorn.run(
app,
host='0.0.0.0',
port=8000,
reload=True
)
# Example of how to test the integration
def test_external_integration():
"""Test that the external integration works correctly."""
from fastapi.testclient import TestClient
app = create_external_app()
client = TestClient(app)
# Test custom routes
response = client.get('/enterprise/status')
assert response.status_code == 200
assert response.json()['mode'] == 'enterprise'
# Test tenant-specific routes
response = client.get('/enterprise/tenant/acme-corp/info')
assert response.status_code == 200
data = response.json()
assert data['tenant_id'] == 'acme-corp'
assert 'enterprise_features' in data['features']
# Test OpenHands routes still work
response = client.get('/openhands/api/health')
assert response.status_code == 200
print("✅ All integration tests passed!")
if __name__ == '__main__':
# Run tests
test_external_integration()
+83 -8
View File
@@ -1,11 +1,86 @@
"""OpenHands FastAPI application.
import contextlib
import warnings
from contextlib import asynccontextmanager
from typing import AsyncIterator
This module provides the main FastAPI application for OpenHands.
For extensibility and custom configurations, use the factory pattern
from openhands.server.factory instead of importing this app directly.
"""
from fastapi.routing import Mount
from openhands.server.factory import create_default_app
with warnings.catch_warnings():
warnings.simplefilter('ignore')
# Create the default OpenHands app using the factory
app = create_default_app()
from fastapi import (
FastAPI,
Request,
)
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.integrations.service_types import AuthenticationError
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.mcp import mcp_server
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
mcp_app = mcp_server.http_app(path='/mcp')
def combine_lifespans(*lifespans):
# Create a combined lifespan to manage multiple session managers
@contextlib.asynccontextmanager
async def combined_lifespan(app):
async with contextlib.AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
async with conversation_manager:
yield
app = FastAPI(
title='OpenHands',
description='OpenHands: Code Less, Make More',
version=__version__,
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
routes=[Mount(path='/mcp', app=mcp_app)],
)
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return JSONResponse(
status_code=401,
content=str(exc),
)
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)
-38
View File
@@ -1,38 +0,0 @@
"""Server context system for dependency injection and extensibility.
This module provides a context-based approach to managing server dependencies,
replacing the global variables in shared.py. This enables:
- Dependency injection for better testability
- Easy extensibility for custom implementations
- Per-request contexts for multi-user scenarios
- No import-time dependencies on environment variables
Usage:
# In route handlers
from openhands.server.context import get_server_context
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use context instead of importing from shared
# For custom extensions
from openhands.server.factory import create_openhands_app
app = create_openhands_app(context_factory=MyServerContext)
"""
from .context_provider import (
create_server_context,
get_server_context,
)
from .server_context import ServerContext
__all__ = [
'ServerContext',
'get_server_context',
'create_server_context',
]
@@ -1,90 +0,0 @@
"""Context provider system for dependency injection.
This module provides the default context provider for OpenHands routes.
For custom context implementations, use the factory pattern from
openhands.server.factory instead of modifying global state.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import Request
from .server_context import ServerContext
async def get_server_context(request: Request) -> ServerContext:
"""Get server context from request, with caching.
This function provides dependency injection for ServerContext. It:
1. Checks if a context is already cached on the request
2. If not, creates a new context using the configured context class
3. Caches the context on the request for subsequent use
This enables:
- Per-request context instances for multi-user scenarios
- Lazy initialization of dependencies
- Easy testing with mock contexts
Args:
request: FastAPI request object
Returns:
ServerContext: The server context instance for this request
Usage:
from fastapi import Depends, Request
from openhands.server.context import get_server_context, ServerContext
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use context
"""
# Check if context is already cached on the request
context = getattr(request.state, 'server_context', None)
if context:
return context
# Create default context instance
from .default_server_context import DefaultServerContext
context = DefaultServerContext()
# Cache on request for subsequent use
request.state.server_context = context
return context
def create_server_context(context_class: str | None = None) -> ServerContext:
"""Create a server context instance directly.
This is useful for testing, CLI applications, or other scenarios where
you need a context outside of a FastAPI request.
Args:
context_class: Optional context class name. If None, uses DefaultServerContext.
Returns:
ServerContext: New context instance
Example:
# For testing with custom context
from openhands.utils.import_utils import get_impl
context_cls = get_impl(ServerContext, 'tests.mocks.MockServerContext')
context = context_cls()
# Use default context
context = create_server_context()
"""
if context_class:
from openhands.utils.import_utils import get_impl
context_cls = get_impl(ServerContext, context_class)
return context_cls()
else:
from .default_server_context import DefaultServerContext
return DefaultServerContext()
@@ -1,180 +0,0 @@
"""Default ServerContext implementation that maintains current behavior."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from openhands.server.context.server_context import ServerContext
# Lazy imports to avoid import-time dependencies
if TYPE_CHECKING:
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
class DefaultServerContext(ServerContext):
"""Default implementation that maintains current behavior.
This implementation replicates the exact behavior of the original shared.py
globals, ensuring backward compatibility while providing the extensibility
framework for SaaS implementations.
All dependencies are lazily initialized to avoid import-time side effects
and allow for proper testing and mocking.
"""
def __init__(self):
# Lazy initialization - only create instances when requested
self._config: OpenHandsConfig | None = None
self._server_config: ServerConfig | None = None
self._file_store: FileStore | None = None
self._socketio_server = None
self._conversation_manager: ConversationManager | None = None
self._monitoring_listener: MonitoringListener | None = None
self._settings_store_class: type[SettingsStore] | None = None
self._secrets_store_class: type[SecretsStore] | None = None
self._conversation_store_class: type[ConversationStore] | None = None
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration."""
if self._config is None:
from openhands.core.config import load_openhands_config
self._config = load_openhands_config()
return self._config
def get_server_config(self) -> ServerConfig:
"""Get the server configuration."""
if self._server_config is None:
from openhands.server.config.server_config import load_server_config
self._server_config = load_server_config()
return self._server_config
def get_file_store(self) -> FileStore:
"""Get the file store implementation."""
if self._file_store is None:
from openhands.storage import get_file_store
config = self.get_config()
self._file_store = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
return self._file_store
def get_socketio_server(self):
"""Get the Socket.IO server instance."""
if self._socketio_server is None:
import socketio
# Replicate the original Redis client manager logic
client_manager = None
redis_host = os.environ.get('REDIS_HOST')
if redis_host:
client_manager = socketio.AsyncRedisManager(
f'redis://{redis_host}',
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
)
self._socketio_server = socketio.AsyncServer(
async_mode='asgi',
cors_allowed_origins='*',
client_manager=client_manager,
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
max_http_buffer_size=4 * 1024 * 1024,
)
return self._socketio_server
def get_conversation_manager(self) -> ConversationManager:
"""Get the conversation manager implementation."""
if self._conversation_manager is None:
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
config = self.get_config()
file_store = self.get_file_store()
sio = self.get_socketio_server()
monitoring_listener = self.get_monitoring_listener()
ConversationManagerImpl = get_impl(
ConversationManager,
server_config.conversation_manager_class,
)
self._conversation_manager = ConversationManagerImpl.get_instance(
sio, config, file_store, server_config, monitoring_listener
)
return self._conversation_manager
def get_monitoring_listener(self) -> MonitoringListener:
"""Get the monitoring listener implementation."""
if self._monitoring_listener is None:
from openhands.server.monitoring import MonitoringListener
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
config = self.get_config()
MonitoringListenerImpl = get_impl(
MonitoringListener,
server_config.monitoring_listener_class,
)
self._monitoring_listener = MonitoringListenerImpl.get_instance(config)
return self._monitoring_listener
def get_settings_store_class(self) -> type[SettingsStore]:
"""Get the settings store class."""
if self._settings_store_class is None:
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._settings_store_class = get_impl(
SettingsStore, server_config.settings_store_class
)
return self._settings_store_class
def get_secrets_store_class(self) -> type[SecretsStore]:
"""Get the secrets store class."""
if self._secrets_store_class is None:
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._secrets_store_class = get_impl(
SecretsStore, server_config.secret_store_class
)
return self._secrets_store_class
def get_conversation_store_class(self) -> type[ConversationStore]:
"""Get the conversation store class."""
if self._conversation_store_class is None:
from openhands.storage.conversation.conversation_store import (
ConversationStore,
)
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._conversation_store_class = get_impl(
ConversationStore,
server_config.conversation_store_class,
)
return self._conversation_store_class
-134
View File
@@ -1,134 +0,0 @@
"""Base ServerContext class for dependency injection and extensibility."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import socketio
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
class ServerContext(ABC):
"""Base class for server context that holds all server dependencies.
This replaces the global variables in shared.py and allows for:
- Dependency injection for better testability
- Easy extensibility for SaaS and enterprise features
- Per-request contexts for multi-user scenarios
- No import-time dependencies on environment variables
SaaS implementations can extend this class to provide:
- Custom server configurations with enterprise features
- Multi-tenant storage implementations
- Per-user/per-organization contexts
- Custom conversation managers and monitoring
Example SaaS extension:
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
super().__init__()
self.user_id = user_id
self.org_id = org_id
def get_server_config(self) -> ServerConfig:
return SaaSServerConfig(org_id=self.org_id)
def get_file_store(self) -> FileStore:
return MultiTenantFileStore(self.user_id, self.org_id)
"""
@abstractmethod
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration.
Returns:
OpenHandsConfig: The core application configuration
"""
raise NotImplementedError
@abstractmethod
def get_server_config(self) -> ServerConfig:
"""Get the server configuration.
Returns:
ServerConfig: Server-specific configuration including feature flags,
authentication settings, and component class names
"""
raise NotImplementedError
@abstractmethod
def get_file_store(self) -> FileStore:
"""Get the file store implementation.
Returns:
FileStore: File storage implementation for handling uploads,
downloads, and file management
"""
raise NotImplementedError
@abstractmethod
def get_socketio_server(self) -> socketio.AsyncServer:
"""Get the Socket.IO server instance.
Returns:
socketio.AsyncServer: The Socket.IO server for real-time communication
"""
raise NotImplementedError
@abstractmethod
def get_conversation_manager(self) -> ConversationManager:
"""Get the conversation manager implementation.
Returns:
ConversationManager: Manager for handling conversation lifecycle,
agent sessions, and conversation state
"""
raise NotImplementedError
@abstractmethod
def get_monitoring_listener(self) -> MonitoringListener:
"""Get the monitoring listener implementation.
Returns:
MonitoringListener: Listener for monitoring events and metrics
"""
raise NotImplementedError
@abstractmethod
def get_settings_store_class(self) -> type[SettingsStore]:
"""Get the settings store class.
Returns:
type[SettingsStore]: Class for storing and retrieving user settings
"""
raise NotImplementedError
@abstractmethod
def get_secrets_store_class(self) -> type[SecretsStore]:
"""Get the secrets store class.
Returns:
type[SecretsStore]: Class for storing and retrieving user secrets
"""
raise NotImplementedError
@abstractmethod
def get_conversation_store_class(self) -> type[ConversationStore]:
"""Get the conversation store class.
Returns:
type[ConversationStore]: Class for storing and retrieving conversations
"""
raise NotImplementedError
-227
View File
@@ -1,227 +0,0 @@
"""FastAPI app factory for OpenHands server.
This module provides a factory function to create OpenHands FastAPI applications
with configurable dependencies, enabling external repositories to extend OpenHands
without relying on global variables or environment variable configuration.
Example usage for external repositories:
# In your external repo
from openhands.server.factory import create_openhands_app
from my_custom_context import MyServerContext
# Create OpenHands app with your custom context
openhands_app = create_openhands_app(
context_factory=lambda: MyServerContext(),
include_oss_routes=False, # Skip OSS-specific routes
custom_lifespan=my_custom_lifespan
)
# Add your own routes
@openhands_app.get('/my-custom-route')
async def my_route():
return {'message': 'Hello from my extension!'}
# Or create your own app and include OpenHands routes
from fastapi import FastAPI
my_app = FastAPI()
my_app.mount('/openhands', openhands_app)
"""
import contextlib
import warnings
from contextlib import asynccontextmanager
from typing import AsyncIterator, Callable, Optional
from fastapi import FastAPI
from fastapi.routing import Mount
with warnings.catch_warnings():
warnings.simplefilter('ignore')
from fastapi import Request
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.integrations.service_types import AuthenticationError
from openhands.server.context.server_context import ServerContext
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.mcp import mcp_server
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.types import AppMode
def combine_lifespans(*lifespans):
"""Combine multiple FastAPI lifespans into one."""
@contextlib.asynccontextmanager
async def combined_lifespan(app):
async with contextlib.AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
def create_openhands_app(
context_factory: Optional[Callable[[], ServerContext]] = None,
include_oss_routes: bool = True,
include_mcp: bool = True,
custom_lifespan: Optional[Callable] = None,
title: str = 'OpenHands',
description: str = 'OpenHands: Code Less, Make More',
) -> FastAPI:
"""Create a FastAPI application with OpenHands routes and configurable dependencies.
This factory function allows external repositories to create OpenHands applications
with their own context implementations and configuration, without relying on
global variables or environment variable configuration.
Args:
context_factory: Factory function to create ServerContext instances.
If None, uses DefaultServerContext.
include_oss_routes: Whether to include OSS-specific routes (like git).
include_mcp: Whether to include MCP (Model Context Protocol) routes.
custom_lifespan: Custom lifespan function for the FastAPI app.
title: Title for the FastAPI app.
description: Description for the FastAPI app.
Returns:
FastAPI: Configured FastAPI application with OpenHands routes.
Example:
# Basic usage with default context
app = create_openhands_app()
# Custom context for multi-tenant SaaS
def create_saas_context():
return SaaSServerContext(tenant_id='default')
app = create_openhands_app(
context_factory=create_saas_context,
include_oss_routes=False
)
# External repo extending OpenHands
from my_extension import MyServerContext, my_lifespan
app = create_openhands_app(
context_factory=lambda: MyServerContext(),
custom_lifespan=my_lifespan
)
"""
# Import default context here to avoid import-time dependencies
from openhands.server.context.default_server_context import DefaultServerContext
# Use provided context factory or default
if context_factory is None:
context_factory = DefaultServerContext
# Create a context instance to get configuration
context = context_factory()
server_config = context.get_server_config()
conversation_manager = context.get_conversation_manager()
# Build lifespan functions
lifespans = []
# Add conversation manager lifespan
@asynccontextmanager
async def conversation_lifespan(app: FastAPI) -> AsyncIterator[None]:
async with conversation_manager:
yield
lifespans.append(conversation_lifespan)
# Add MCP lifespan if requested
if include_mcp:
mcp_app = mcp_server.http_app(path='/mcp')
lifespans.append(mcp_app.lifespan)
# Add custom lifespan if provided
if custom_lifespan:
lifespans.append(custom_lifespan)
# Create routes list
routes = []
if include_mcp:
routes.append(Mount(path='/mcp', app=mcp_app))
# Create FastAPI app
app = FastAPI(
title=title,
description=description,
version=__version__,
lifespan=combine_lifespans(*lifespans) if lifespans else None,
routes=routes,
)
# Add exception handlers
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return JSONResponse(
status_code=401,
content=str(exc),
)
# Override the context dependency for all routes
# This is the key: we inject our context factory into the dependency system
from openhands.server.context.context_provider import get_server_context
async def custom_get_server_context(request: Request) -> ServerContext:
"""Custom context provider that uses our factory."""
# Check if context is already cached on the request
context = getattr(request.state, 'server_context', None)
if context:
return context
# Create new context instance using our factory
context = context_factory()
# Cache on request for subsequent use
request.state.server_context = context
return context
# Override the dependency
app.dependency_overrides[get_server_context] = custom_get_server_context
# Include all the standard OpenHands routes
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
# Conditionally include OSS routes based on server config
if include_oss_routes and server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)
return app
# For backward compatibility, create the default app
def create_default_app() -> FastAPI:
"""Create the default OpenHands FastAPI app.
This is equivalent to the old app.py behavior but using the factory pattern.
Used for backward compatibility.
"""
return create_openhands_app()
+70 -69
View File
@@ -1,76 +1,77 @@
"""Shared server dependencies - DEPRECATED.
import os
This module is deprecated and maintained only for backward compatibility.
New code should use the context system from openhands.server.context instead.
import socketio
from dotenv import load_dotenv
The context system provides:
- Better dependency injection
- Easier testing and mocking
- SaaS extensibility
- Per-request contexts
- No import-time side effects
from openhands.core.config import load_openhands_config
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig, load_server_config
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.server.types import ServerConfigInterface
from openhands.storage import get_file_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
Migration guide:
# Old way (deprecated)
from openhands.server.shared import config, server_config
load_dotenv()
# New way (recommended)
from openhands.server.context import get_server_context
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
server_config = context.get_server_config()
"""
import warnings
from openhands.server.context.default_server_context import DefaultServerContext
# Load environment variables for backward compatibility
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# dotenv is optional
pass
# Create default context for backward compatibility
_default_context = DefaultServerContext()
# Issue deprecation warning when this module is imported
warnings.warn(
'openhands.server.shared is deprecated. Use openhands.server.context instead. '
'See the module docstring for migration guidance.',
DeprecationWarning,
stacklevel=2,
config: OpenHandsConfig = load_openhands_config()
server_config_interface: ServerConfigInterface = load_server_config()
assert isinstance(server_config_interface, ServerConfig), (
'Loaded server config interface is not a ServerConfig, despite this being assumed'
)
server_config: ServerConfig = server_config_interface
file_store: FileStore = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
client_manager = None
redis_host = os.environ.get('REDIS_HOST')
if redis_host:
client_manager = socketio.AsyncRedisManager(
f'redis://{redis_host}',
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
)
# Module-level lazy loading using __getattr__
def __getattr__(name: str):
"""Lazy loading for backward compatibility globals."""
if name == 'config':
return _default_context.get_config()
elif name == 'server_config':
return _default_context.get_server_config()
elif name == 'file_store':
return _default_context.get_file_store()
elif name == 'sio':
return _default_context.get_socketio_server()
elif name == 'conversation_manager':
return _default_context.get_conversation_manager()
elif name == 'monitoring_listener':
return _default_context.get_monitoring_listener()
elif name == 'SettingsStoreImpl':
return _default_context.get_settings_store_class()
elif name == 'SecretsStoreImpl':
return _default_context.get_secrets_store_class()
elif name == 'ConversationStoreImpl':
return _default_context.get_conversation_store_class()
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
sio = socketio.AsyncServer(
async_mode='asgi',
cors_allowed_origins='*',
client_manager=client_manager,
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
max_http_buffer_size=4 * 1024 * 1024,
)
MonitoringListenerImpl = get_impl(
MonitoringListener,
server_config.monitoring_listener_class,
)
monitoring_listener = MonitoringListenerImpl.get_instance(config)
ConversationManagerImpl = get_impl(
ConversationManager,
server_config.conversation_manager_class,
)
conversation_manager = ConversationManagerImpl.get_instance(
sio, config, file_store, server_config, monitoring_listener
)
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class)
SecretsStoreImpl = get_impl(SecretsStore, server_config.secret_store_class)
ConversationStoreImpl = get_impl(
ConversationStore,
server_config.conversation_store_class,
)