Compare commits

...

13 Commits

Author SHA1 Message Date
5a89f46e3d fix: replace per-path proxy with cookie-based catch-all routing
The /session/{id} URL prefix collided with OpenCode's internal
/session/{slug} SPA routes, causing a blank page. Now /c/{id} is
a thin entry point that sets a session cookie and redirects to /,
where the SPA loads at root with its router working correctly.

This also replaces ~50 individual per-path proxy route handlers
with a single /{path:path} catch-all, and simplifies the Caddyfile
from ~180 lines to ~17.
2026-02-16 10:40:17 +01:00
9683cf280b fix: add SSE streaming proxy and robust make try startup
The SSE proxy was buffering the entire response body with a 30s read
timeout, causing 504s on the OpenCode /global/event stream. Add a
streaming path that detects SSE requests (by Accept header or /event
path) and returns a StreamingResponse with no read timeout.

Also fix the make try target to poll the health endpoint for Docker
readiness and wait for the container to reach running status before
opening the browser.
2026-02-16 00:38:57 +01:00
fb2c1f0c60 fix: make try target auto-starts stack and uses correct API paths
The try and session targets were hitting /sessions directly instead of
/api/sessions (Caddy strips the /api prefix before proxying). Also, try
now depends on up and waits for health check before creating a session.
2026-02-16 00:18:34 +01:00
217d41d680 test: strengthen Cypress e2e tests with real API assertions
- Remove blanket uncaught:exception suppressor (API-only tests)
- Trim smoke test to single infra-verification assertion
- Rewrite health test with strict status/field assertions, no failOnStatusCode
- Add session CRUD tests (create, get, list, delete, 404 cases, cleanup)
- Use Cypress.env('API_URL') instead of baseUrl to avoid blocking smoke tests
- Remove unused main and type fields from package.json
2026-02-15 23:57:48 +01:00
991080ae2b test: add initial Cypress e2e test infrastructure
Smoke tests for verifying Cypress runs, plus basic API tests
for health and sessions endpoints.
2026-02-15 23:05:56 +01:00
3feedd5698 consolidated readme 2026-02-08 20:27:35 +01:00
eb8553ce0b security: lock down OpenCode containers to read-only legal research
Add defense-in-depth restrictions via agent config and global permissions:
- Global permission layer denies bash, edit, webfetch, lsp
- Build agent tools restricted to read-only (grep/glob/list/read/todo)
- General/explore subagents locked to read-only
- Plan agent disabled to prevent mode switching
- Custom system prompt for legal research context (temp=0.2)
2026-02-08 20:22:57 +01:00
7dae8faf62 security: fix timing attack vulnerability and incorrect method call
- Use secrets.compare_digest() for token comparison instead of == to
  prevent timing-based attacks that could leak token information
- Fix rotate_session_auth_token() to call the correct method
  rotate_session_token() instead of non-existent rotate_session_auth_token()
2026-02-05 00:36:07 +01:00
2cb5263d9e feat: add comprehensive OpenCode API endpoint proxies
Added proxy routes for all OpenCode internal API endpoints to support
full application functionality when accessed via session manager:
- project, agent, config, model endpoints
- thread, chat, conversation endpoints
- command, mcp, lsp, vcs endpoints
- permission, question, event, status endpoints
- internal session endpoint (distinct from container sessions)

Also updated Caddyfile for routing configuration.
2026-02-05 00:33:58 +01:00
d6f2ea90a8 fix: add missing _get_container_info method to AsyncDockerClient
docker_service.get_container_info() was calling self._docker_client._get_container_info()
but AsyncDockerClient didn't have this method, causing silent AttributeError and
returning None, which triggered false health check failures.

Added _get_container_info() using aiodocker's container.show() to properly retrieve
container state information for health monitoring.
2026-02-04 22:04:29 +01:00
69d18cc494 fix: session stability improvements
- Fix docker client initialization bug in app.py (context manager was closing client)
- Add restart_session() method to preserve session IDs during container restarts
- Add 60-second startup grace period before health checking new sessions
- Fix _stop_container and _get_container_info to use docker_service API consistently
- Disable mDNS in Dockerfile to prevent Bonjour service name conflicts
- Remove old container before restart to free port bindings
2026-02-04 19:10:03 +01:00
05aa70c4af connected zen 2026-02-03 00:36:22 +01:00
9281c0e02a refactored the big main.py file 2026-02-03 00:17:26 +01:00
30 changed files with 3940 additions and 1656 deletions

5
.gitignore vendored
View File

@@ -1 +1,6 @@
__pycache__ __pycache__
.env
node_modules
cypress/screenshots
cypress/videos
cypress/downloads

View File

@@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \
RUN curl -fsSL https://opencode.ai/install | bash RUN curl -fsSL https://opencode.ai/install | bash
# Copy OpenCode configuration # Copy OpenCode configuration
COPY ../config_opencode /root/.config/opencode COPY config_opencode /root/.config/opencode
# Copy session manager # Copy session manager
COPY . /app COPY . /app
@@ -19,8 +19,8 @@ COPY . /app
# Install Python dependencies # Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Create working directory # Create working directory and auth directory
RUN mkdir -p /app/somedir RUN mkdir -p /app/somedir /root/.local/share/opencode
# Expose port # Expose port
EXPOSE 8080 EXPOSE 8080
@@ -28,5 +28,5 @@ EXPOSE 8080
# Set environment variables # Set environment variables
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
# Start OpenCode server # Start OpenCode server (mDNS disabled to prevent conflicts between containers)
CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080 --mdns"] CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080"]

View File

@@ -29,14 +29,30 @@ logs:
# Create a new session and display its info # Create a new session and display its info
session: session:
@echo "Creating new session..." @echo "Creating new session..."
@curl -s -X POST http://localhost:8080/sessions | jq . @curl -s -X POST http://localhost:8080/api/sessions | jq .
# Try the web interface - creates a session and opens it # Try the web interface - starts stack, creates a session and opens it
try: try: up
@echo "Creating session and opening web interface..." @echo "Waiting for services to be ready (Docker daemon can take ~30s)..."
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/sessions | jq -r '.session_id') && \ @for i in $$(seq 1 60); do \
echo "Session created: $$SESSION_ID" && \ STATUS=$$(curl -sf http://localhost:8080/api/health 2>/dev/null | jq -r '.docker // false') && \
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \ [ "$$STATUS" = "true" ] && break; \
xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ printf '.'; \
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ sleep 1; \
echo "Visit: http://localhost:8080/session/$$SESSION_ID" done
@echo ""
@echo "Creating session..."
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/api/sessions | jq -r '.session_id') && \
echo "Session $$SESSION_ID created, waiting for container to start..." && \
for i in $$(seq 1 30); do \
S=$$(curl -sf http://localhost:8080/api/sessions/$$SESSION_ID 2>/dev/null | jq -r '.status // "unknown"') && \
[ "$$S" = "running" ] && break; \
[ "$$S" = "error" ] && echo "Container failed to start" && exit 1; \
printf '.'; \
sleep 1; \
done && \
echo "" && \
echo "Opening http://localhost:8080/c/$$SESSION_ID" && \
(xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
echo "Visit: http://localhost:8080/c/$$SESSION_ID")

View File

@@ -1,66 +0,0 @@
# Lovdata Chat Development Environment
This setup creates a container-per-visitor architecture for the Norwegian legal research chat interface with socket-based Docker communication.
## Quick Start
1. **Set up environment variables:**
```bash
cp .env.example .env
# Edit .env with your API keys and MCP server URL
```
3. **Start the services:**
```bash
docker-compose up --build
```
4. **Create a session:**
```bash
curl http://localhost/api/sessions -X POST
```
5. **Access the chat interface:**
Open the returned URL in your browser
## Architecture
- **session-manager**: FastAPI service managing container lifecycles with socket-based Docker communication
- **lovdata-mcp**: External Norwegian legal research MCP server (configured via MCP_SERVER env var)
- **caddy**: Reverse proxy with dynamic session-based routing
## Security Features
- **Socket-based Docker communication**: Direct Unix socket access for container management
- **Container isolation**: Each visitor gets dedicated container with resource limits
- **Automatic cleanup**: Sessions expire after 60 minutes of inactivity
- **Resource quotas**: 4GB RAM, 1 CPU core per container, max 3 concurrent sessions
## Development Notes
- Session data persists in ./sessions/ directory
- Docker socket mounted from host for development
- External MCP server configured via environment variables
- Health checks ensure service reliability
## API Endpoints
- `POST /api/sessions` - Create new session
- `GET /api/sessions` - List all sessions
- `GET /api/sessions/{id}` - Get session info
- `DELETE /api/sessions/{id}` - Delete session
- `POST /api/cleanup` - Manual cleanup
- `GET /api/health` - Health check
- `/{path}` - Dynamic proxy routing (with X-Session-ID header)
## Environment Variables
```bash
# Required
MCP_SERVER=http://your-lovdata-mcp-server:8001
# Optional LLM API keys
OPENAI_API_KEY=your_key
ANTHROPIC_API_KEY=your_key
GOOGLE_API_KEY=your_key
```

329
README.md
View File

@@ -1,239 +1,162 @@
# Lovdata Chat Interface # Lovdata Chat Interface
A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server. A container-per-session architecture for Norwegian legal research. Each user session gets an isolated [OpenCode](https://opencode.ai/) container connected to the external [Lovdata MCP server](https://modelcontextprotocol.io/), which provides 15+ tools for searching Norwegian laws, provisions, and cross-references.
## Overview
This project creates a chat interface where users can:
- Choose from multiple LLM providers (OpenAI, Anthropic, Google Gemini)
- Have conversations enhanced with Norwegian legal document search capabilities
- Access laws, regulations, and legal provisions through AI-powered semantic search
- Receive properly cited legal information with cross-references
## Architecture ## Architecture
### Backend (FastAPI) ```
- **LLM Provider Layer**: Abstract interface supporting multiple LLM providers with tool calling Users → Caddy (reverse proxy) → Session Manager (FastAPI)
- **MCP Integration**: Client connection to lovdata-ai MCP server
- **Skill System**: Norwegian legal research guidance and best practices Docker-in-Docker daemon
- **Chat Management**: Conversation history, streaming responses, session management ↓ ↓ ↓
[OC 1] [OC 2] [OC 3] ← OpenCode containers
↓ ↓ ↓
Lovdata MCP Server (external)
LLM APIs (OpenAI/Anthropic/Google)
```
### Frontend (Next.js) | Component | Purpose |
- **Chat Interface**: Real-time messaging with streaming responses |-----------|---------|
- **Model Selector**: Dropdown to choose LLM provider and model | **Session Manager** | FastAPI service managing OpenCode container lifecycles |
- **Tool Visualization**: Display when legal tools are being used | **OpenCode Containers** | Isolated chat environments with MCP integration |
- **Citation Rendering**: Properly formatted legal references and cross-references | **Lovdata MCP Server** | External Norwegian legal research (laws, provisions, cross-references) |
| **Caddy** | Reverse proxy with dynamic session-based routing |
| **PostgreSQL** | Session persistence across restarts |
| **Docker-in-Docker** | TLS-secured Docker daemon for container management |
### External Dependencies ### Session Manager Components
- **Lovdata MCP Server**: Provides 15+ tools for Norwegian legal research
- **PostgreSQL Database**: Vector embeddings for semantic search
- **LLM APIs**: OpenAI, Anthropic, Google Gemini (with API keys)
## Supported LLM Providers ```
main.py → FastAPI endpoints, session lifecycle orchestration
docker_service.py → Docker abstraction layer (testable, mockable)
async_docker_client.py → Async Docker operations
database.py → PostgreSQL session persistence with asyncpg
session_auth.py → Token-based session authentication
container_health.py → Health monitoring and auto-recovery
resource_manager.py → CPU/memory limits, throttling
http_pool.py → Connection pooling for container HTTP requests
host_ip_detector.py → Docker host IP detection
logging_config.py → Structured JSON logging with context
```
| Provider | Models | Tool Support | Notes | ## Quick Start
|----------|--------|--------------|-------|
| OpenAI | GPT-4, GPT-4o | ✅ Native | Requires API key |
| Anthropic | Claude-3.5-Sonnet | ✅ Native | Requires API key |
| Google | Gemini-1.5-Pro | ✅ Function calling | Requires API key |
| Local | Ollama models | ⚠️ Limited | Self-hosted option |
## MCP Tools Available 1. **Set up environment variables:**
```bash
cp .env.example .env
# Edit .env with your API keys and MCP server URL
```
The interface integrates all tools from the lovdata-ai MCP server: 2. **Start the services:**
```bash
docker-compose up --build
```
### Law Document Tools 3. **Create a session:**
- `get_law`: Retrieve specific laws by ID or title ```bash
- `list_laws`: Browse laws with filtering and pagination curl http://localhost/api/sessions -X POST
- `get_law_content`: Get HTML content of laws ```
- `get_law_text`: Get plain text content
### Search Tools 4. **Access the chat interface** at the URL returned in step 3.
- `search_laws_fulltext`: Full-text search in laws
- `search_laws_semantic`: Semantic search using vector embeddings
- `search_provisions_fulltext`: Full-text search in provisions
- `search_provisions_semantic`: Semantic search in provisions
### Provision Tools ## Development
- `get_provision`: Get individual legal provisions
- `list_provisions`: List all provisions in a law
- `get_provisions_batch`: Bulk retrieval for RAG applications
### Reference Tools ### Running the Stack
- `get_cross_references`: Find references from/to provisions
- `resolve_reference`: Parse legal reference strings (e.g., "lov/2014-06-20-42/§8")
## Skills Integration
The system loads Norwegian legal research skills that ensure:
- Proper citation standards (Lovdata URL formatting)
- Appropriate legal terminology usage
- Clear distinction between information and legal advice
- Systematic amendment tracking
- Cross-reference analysis
## Implementation Plan
### Phase 1: Core Infrastructure
1. **Project Structure Setup**
- Create backend (FastAPI) and frontend (Next.js) directories
- Set up Python virtual environment and Node.js dependencies
- Configure development tooling (linting, testing, formatting)
2. **LLM Provider Abstraction**
- Create abstract base class for LLM providers
- Implement OpenAI, Anthropic, and Google Gemini clients
- Add tool calling support and response streaming
- Implement provider switching logic
3. **MCP Server Integration**
- Build MCP client to connect to lovdata-ai server
- Create tool registry and execution pipeline
- Add error handling and retry logic
- Implement tool result formatting for LLM consumption
### Phase 2: Chat Functionality
4. **Backend API Development**
- Create chat session management endpoints
- Implement conversation history storage
- Add streaming response support
- Build health check and monitoring endpoints
5. **Skill System Implementation**
- Create skill loading and parsing system
- Implement skill application to LLM prompts
- Add skill validation and error handling
- Create skill management API endpoints
### Phase 3: Frontend Development
6. **Chat Interface**
- Build responsive chat UI with message history
- Implement real-time message streaming
- Add message formatting for legal citations
- Create conversation management (new chat, clear history)
7. **Model Selection UI**
- Create LLM provider and model selector
- Add API key management (secure storage)
- Implement model switching during conversations
- Add model capability indicators
8. **Tool Usage Visualization**
- Display when MCP tools are being used
- Show tool execution results in chat
- Add legal citation formatting
- Create expandable tool result views
### Phase 4: Deployment & Production
9. **Containerization**
- Create Dockerfiles for backend and frontend
- Set up Docker Compose for development
- Configure production Docker Compose
- Add environment variable management
10. **Deployment Configuration**
- Set up CI/CD pipeline (GitHub Actions)
- Configure cloud deployment (Railway/Render)
- Add reverse proxy configuration
- Implement SSL certificate management
11. **Monitoring & Error Handling**
- Add comprehensive logging
- Implement error tracking and reporting
- Create health check endpoints
- Add rate limiting and abuse protection
12. **Documentation**
- Create setup and deployment guides
- Document API endpoints
- Add user documentation
- Create troubleshooting guides
## Development Setup
### Prerequisites
- Python 3.12+
- Node.js 18+
- Docker and Docker Compose
- API keys for desired LLM providers
### Local Development
```bash ```bash
# Clone and setup # Start all services (session-manager, docker-daemon, caddy)
git clone <repository> docker-compose up --build
cd lovdata-chat
# Backend setup # Start in background
cd backend docker-compose up -d --build
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate # View logs
docker-compose logs -f session-manager
# Stop services
docker-compose down
```
### Session Management API
```bash
POST /api/sessions # Create new session
GET /api/sessions # List all sessions
GET /api/sessions/{id} # Get session info
DELETE /api/sessions/{id} # Delete session
POST /api/cleanup # Manual cleanup
GET /api/health # Health check
```
### Running Locally (without Docker)
```bash
cd session-manager
pip install -r requirements.txt pip install -r requirements.txt
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Frontend setup
cd ../frontend
npm install
# Start development servers
docker-compose -f docker-compose.dev.yml up
``` ```
### Environment Variables ### Testing
Test scripts live in `docker/scripts/` and are self-contained:
```bash ```bash
# Backend python docker/scripts/test-docker-service.py
LOVDATA_MCP_URL=http://localhost:8001 python docker/scripts/test-async-docker.py
OPENAI_API_KEY=your_key_here python docker/scripts/test-resource-limits.py
ANTHROPIC_API_KEY=your_key_here python docker/scripts/test-session-auth.py
GOOGLE_API_KEY=your_key_here python docker/scripts/test-database-persistence.py
python docker/scripts/test-container-health.py
# Frontend python docker/scripts/test-http-connection-pool.py
NEXT_PUBLIC_API_URL=http://localhost:8000 python docker/scripts/test-host-ip-detection.py
python docker/scripts/test-structured-logging.py
``` ```
## Deployment Options ### Building the OpenCode Image
### Cloud Deployment (Recommended) ```bash
- **Frontend**: Vercel or Netlify make build MCP_SERVER=http://your-lovdata-server:8001
- **Backend**: Railway, Render, or Fly.io make run # Run interactively
- **Database**: Use existing lovdata-ai PostgreSQL instance make clean # Clean up
```
### Self-Hosted Deployment ## Environment Configuration
- **Docker Compose**: Full stack containerization
- **Reverse Proxy**: Nginx or Caddy
- **SSL**: Let's Encrypt automatic certificates
## Security Considerations Required variables (see `.env.example`):
- API keys stored securely (environment variables, secret management) ```bash
- Rate limiting on chat endpoints MCP_SERVER=http://localhost:8001 # External Lovdata MCP server URL
- Input validation and sanitization
- CORS configuration for frontend-backend communication
- Audit logging for legal tool usage
## Performance Optimization # Docker TLS (if using TLS instead of socket)
DOCKER_TLS_VERIFY=1
DOCKER_CERT_PATH=/etc/docker/certs
DOCKER_HOST=tcp://host.docker.internal:2376
- Response streaming for real-time chat experience # Optional LLM keys (at least one required for chat)
- MCP tool result caching OPENAI_API_KEY=...
- Conversation history pagination ANTHROPIC_API_KEY=...
- Lazy loading of legal document content GOOGLE_API_KEY=...
- CDN for static frontend assets ```
## Future Enhancements ## Security
- User authentication and conversation persistence **Docker socket**: Default setup uses socket mounting (`/var/run/docker.sock`). For production, enable TLS:
- Advanced citation management and export
- Integration with legal research workflows
- Multi-language support beyond Norwegian
- Advanced analytics and usage tracking
## Contributing ```bash
cd docker && DOCKER_ENV=production ./scripts/generate-certs.sh
./scripts/setup-docker-tls.sh
```
1. Follow the implementation plan phases **Session isolation:**
2. Ensure comprehensive testing for LLM integrations - Each session gets a dedicated container
3. Document API changes and new features - Resource limits: 4GB RAM, 1 CPU core per container
4. Maintain security best practices for API key handling - Max 3 concurrent sessions (configurable via `resource_manager.py`)
- Auto-cleanup after 60 minutes inactivity
- Token-based session authentication
--- ## Further Documentation
**Status**: Planning phase complete. Ready for implementation. - [`CLAUDE.md`](CLAUDE.md) — AI assistant guidance for working with this codebase
- [`LOW_PRIORITY_IMPROVEMENTS.md`](LOW_PRIORITY_IMPROVEMENTS.md) — Backlog of non-critical improvements
**Next Steps**: Begin with Phase 1 - Project Structure Setup - [`docs/project-analysis.md`](docs/project-analysis.md) — Detailed architectural analysis
- `docker/*.md` — Implementation docs for individual components

View File

@@ -2,7 +2,85 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"theme": "opencode", "theme": "opencode",
"autoupdate": false, "autoupdate": false,
"model": "opencode/kimi-k2.5-free",
"plugin": [], "plugin": [],
// Global permissions — defense-in-depth safety net across ALL agents
"permission": {
"bash": "deny",
"edit": "deny",
"webfetch": "deny",
"read": "allow",
"grep": "allow",
"glob": "allow",
"list": "allow",
"todoread": "allow",
"todowrite": "allow",
"lsp": "deny",
"task": "allow",
"skill": "allow"
},
"agent": {
// Primary agent — locked to read-only + Lovdata MCP tools
"build": {
"mode": "primary",
"prompt": "{file:./prompts/legal-research.md}",
"temperature": 0.2,
"tools": {
"bash": false,
"write": false,
"edit": false,
"patch": false,
"webfetch": false,
"read": true,
"grep": true,
"glob": true,
"list": true,
"todowrite": true,
"todoread": true
}
},
// Disable plan agent — users shouldn't switch modes
"plan": {
"mode": "primary",
"disable": true
},
// Lock down general subagent — it normally has full tool access
"general": {
"mode": "subagent",
"tools": {
"bash": false,
"write": false,
"edit": false,
"patch": false,
"webfetch": false,
"read": true,
"grep": true,
"glob": true,
"list": true
}
},
// Explore subagent is already read-only, but be explicit
"explore": {
"mode": "subagent",
"tools": {
"bash": false,
"write": false,
"edit": false,
"patch": false,
"webfetch": false,
"read": true,
"grep": true,
"glob": true,
"list": true
}
}
},
"mcp": { "mcp": {
"sequential-thinking": { "sequential-thinking": {
"type": "local", "type": "local",

View File

@@ -0,0 +1,25 @@
You are a Norwegian legal research assistant powered by Lovdata.
Your role is to help users research Norwegian laws (lover), regulations (forskrifter), and legal concepts using the Lovdata MCP tools available to you.
## What you can do
- Search and retrieve Norwegian laws and regulations via Lovdata
- Explain legal concepts in clear Norwegian (or English when asked)
- Provide proper citations with Lovdata URLs
- Trace cross-references between legal provisions
- Track amendment history
## What you cannot do
- You cannot execute shell commands, create files, or modify files
- You are a research tool, not a lawyer. Always recommend professional legal consultation for specific legal situations
- Clearly distinguish between legal information and legal advice
## Guidelines
- Always cite specific Lovdata URLs with amendment dates
- Distinguish between laws (lover) and regulations (forskrifter)
- Use the correct document ID prefixes: `NL/lov/` for laws, `SF/forskrift/` for regulations
- Consider the hierarchical legal structure and cross-references
- Respond in the same language the user writes in (Norwegian or English)

12
cypress.config.js Normal file
View File

@@ -0,0 +1,12 @@
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
defaultCommandTimeout: 10000,
requestTimeout: 10000,
video: false,
screenshotOnRunFailure: true,
},
});

View File

@@ -0,0 +1,21 @@
/// <reference types="cypress" />
const api = () => Cypress.env('API_URL');
describe('Health API', () => {
it('GET /api/health returns status and required fields', () => {
cy.request(`${api()}/api/health`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('status');
expect(response.body.status).to.be.oneOf([
'healthy',
'degraded',
'unhealthy',
]);
expect(response.body).to.have.property('docker');
expect(response.body).to.have.property('active_sessions');
expect(response.body).to.have.property('timestamp');
expect(response.body).to.have.property('resource_limits');
});
});
});

View File

@@ -0,0 +1,97 @@
/// <reference types="cypress" />
const api = () => Cypress.env('API_URL');
describe('Sessions API', () => {
const createdSessions = [];
afterEach(() => {
createdSessions.splice(0).forEach((id) => {
cy.request({
method: 'DELETE',
url: `${api()}/api/sessions/${id}`,
failOnStatusCode: false,
});
});
});
describe('GET /api/sessions', () => {
it('returns 200 with an array', () => {
cy.request(`${api()}/api/sessions`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
});
});
});
describe('POST /api/sessions', () => {
it('creates a session with expected fields', () => {
cy.request('POST', `${api()}/api/sessions`).then((response) => {
expect(response.status).to.be.oneOf([200, 201]);
expect(response.body).to.have.property('session_id');
expect(response.body).to.have.property('auth_token');
expect(response.body).to.have.property('status');
createdSessions.push(response.body.session_id);
});
});
});
describe('GET /api/sessions/:id', () => {
it('returns the created session', () => {
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
createdSessions.push(createRes.body.session_id);
const id = createRes.body.session_id;
cy.request(`${api()}/api/sessions/${id}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('session_id', id);
});
});
});
it('returns 404 for nonexistent session', () => {
cy.request({
url: `${api()}/api/sessions/nonexistent-id-000`,
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404);
});
});
});
describe('DELETE /api/sessions/:id', () => {
it('deletes a session', () => {
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
const id = createRes.body.session_id;
cy.request('DELETE', `${api()}/api/sessions/${id}`).then(
(response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('message');
}
);
});
});
it('returns 404 for nonexistent session', () => {
cy.request({
method: 'DELETE',
url: `${api()}/api/sessions/nonexistent-id-000`,
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404);
});
});
});
describe('POST /api/cleanup', () => {
it('returns 200 with cleanup message', () => {
cy.request('POST', `${api()}/api/cleanup`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body)
.to.have.property('message')
.that.includes('Cleanup completed');
});
});
});
});

7
cypress/e2e/smoke.cy.js Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="cypress" />
describe('Cypress Infrastructure Smoke Test', () => {
it('should execute a basic assertion', () => {
expect(true).to.be.true;
});
});

6
cypress/support/e2e.js Normal file
View File

@@ -0,0 +1,6 @@
// Cypress E2E support file
// Base URL for API tests. Override with CYPRESS_API_URL env var.
// Not set as Cypress baseUrl to avoid server-reachability checks
// that would block offline tests (smoke).
Cypress.env('API_URL', Cypress.env('API_URL') || 'http://localhost');

View File

@@ -22,6 +22,7 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- ZEN_API_KEY=${ZEN_API_KEY:-}
# Certificate paths (configurable via environment) # Certificate paths (configurable via environment)
- DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem} - DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem}
- DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem} - DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem}
@@ -29,6 +30,8 @@ services:
# Host configuration # Host configuration
- DOCKER_HOST_IP=${DOCKER_HOST_IP:-host.docker.internal} - DOCKER_HOST_IP=${DOCKER_HOST_IP:-host.docker.internal}
- DOCKER_TLS_PORT=${DOCKER_TLS_PORT:-2376} - DOCKER_TLS_PORT=${DOCKER_TLS_PORT:-2376}
# Disable database storage (use in-memory)
- USE_DATABASE_STORAGE=false
networks: networks:
- lovdata-network - lovdata-network
restart: unless-stopped restart: unless-stopped

View File

@@ -2,63 +2,16 @@
# Using HTTP for local development (no SSL warnings) # Using HTTP for local development (no SSL warnings)
# Main web interface - HTTP only for development # Main web interface - HTTP only for development
http://localhost { :80 {
# API endpoints for session management # API endpoints for session management (strip /api prefix)
handle /api/* { handle /api/* {
uri strip_prefix /api uri strip_prefix /api
reverse_proxy session-manager:8000 reverse_proxy session-manager:8000
} }
# Session-specific routing - proxy to session manager for dynamic routing # Everything else goes to session-manager (handles /c/{id} entry
handle /session/{session_id}* { # point and cookie-based proxy to OpenCode containers)
handle {
reverse_proxy session-manager:8000 reverse_proxy session-manager:8000
} }
# OpenCode SPA runtime requests - route based on session cookie
handle /global/* {
reverse_proxy session-manager:8000
}
handle /assets/* {
reverse_proxy session-manager:8000
}
handle /provider/* {
reverse_proxy session-manager:8000
}
handle /provider {
reverse_proxy session-manager:8000
}
handle /project {
reverse_proxy session-manager:8000
}
handle /path {
reverse_proxy session-manager:8000
}
handle /find/* {
reverse_proxy session-manager:8000
}
handle /file {
reverse_proxy session-manager:8000
}
handle /file/* {
reverse_proxy session-manager:8000
}
# Health check
handle /health {
reverse_proxy session-manager:8000
}
# Static files and main interface (fallback)
handle /* {
try_files {path} {path}/ /index.html
file_server
}
} }

2203
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "lovdata-chat",
"version": "1.0.0",
"description": "A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server.",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "cypress run",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"repository": {
"type": "git",
"url": "ssh://git@gitea.torbjorn.org:2222/torbjorn/lovdata-chat.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"cypress": "^15.10.0"
}
}

92
session-manager/app.py Normal file
View File

@@ -0,0 +1,92 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from config import USE_ASYNC_DOCKER, USE_DATABASE_STORAGE
from session_manager import session_manager
from http_pool import init_http_pool, shutdown_http_pool
from database import init_database, shutdown_database, run_migrations
from container_health import (
start_container_health_monitoring,
stop_container_health_monitoring,
)
from logging_config import get_logger, init_logging
from routes import sessions_router, auth_router, health_router, proxy_router
init_logging()
logger = get_logger(__name__)
_use_database_storage = USE_DATABASE_STORAGE
@asynccontextmanager
async def lifespan(app: FastAPI):
global _use_database_storage
logger.info("Starting Session Management Service")
await init_http_pool()
if _use_database_storage:
try:
await init_database()
await run_migrations()
await session_manager._load_sessions_from_database()
logger.info("Database initialized and sessions loaded")
except Exception as e:
logger.error("Database initialization failed", extra={"error": str(e)})
if _use_database_storage:
logger.warning("Falling back to JSON file storage")
_use_database_storage = False
session_manager._load_sessions_from_file()
try:
# Use the session manager's docker_service for health monitoring
# This ensures the docker client stays alive for the lifetime of the application
docker_client = session_manager.docker_service
await start_container_health_monitoring(session_manager, docker_client)
logger.info("Container health monitoring started")
except Exception as e:
logger.error(
"Failed to start container health monitoring", extra={"error": str(e)}
)
async def cleanup_task():
while True:
await session_manager.cleanup_expired_sessions()
await asyncio.sleep(300)
cleanup_coro = asyncio.create_task(cleanup_task())
yield
logger.info("Shutting down Session Management Service")
cleanup_coro.cancel()
await shutdown_http_pool()
try:
await stop_container_health_monitoring()
logger.info("Container health monitoring stopped")
except Exception as e:
logger.error(
"Error stopping container health monitoring", extra={"error": str(e)}
)
if _use_database_storage:
await shutdown_database()
app = FastAPI(
title="Lovdata Chat Session Manager",
description="Manages isolated OpenCode containers for Norwegian legal research sessions",
version="1.0.0",
lifespan=lifespan,
)
app.include_router(sessions_router)
app.include_router(auth_router)
app.include_router(health_router)
app.include_router(proxy_router)

View File

@@ -199,6 +199,23 @@ class AsyncDockerClient:
except DockerError: except DockerError:
return None return None
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed container information (equivalent to docker inspect).
Returns the full container info dict including State, Config, etc.
"""
try:
container = await self._docker.containers.get(container_id)
if container:
# show() returns the full container inspect data
return await container.show()
except DockerError as e:
logger.debug(f"Failed to get container info for {container_id}: {e}")
except Exception as e:
logger.debug(f"Unexpected error getting container info: {e}")
return None
async def list_containers( async def list_containers(
self, all: bool = False, filters: Optional[Dict[str, Any]] = None self, all: bool = False, filters: Optional[Dict[str, Any]] = None
) -> List[DockerContainer]: ) -> List[DockerContainer]:

47
session-manager/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Configuration module for Session Management Service
Centralized configuration loading from environment variables with defaults.
"""
import os
from pathlib import Path
# Session storage configuration
SESSIONS_DIR = Path("/app/sessions")
SESSIONS_FILE = Path("/app/sessions/sessions.json")
# Container configuration
CONTAINER_IMAGE = os.getenv("CONTAINER_IMAGE", "lovdata-opencode:latest")
# Resource limits - configurable via environment variables with defaults
CONTAINER_MEMORY_LIMIT = os.getenv(
"CONTAINER_MEMORY_LIMIT", "4g"
) # Memory limit per container
CONTAINER_CPU_QUOTA = int(
os.getenv("CONTAINER_CPU_QUOTA", "100000")
) # CPU quota (100000 = 1 core)
CONTAINER_CPU_PERIOD = int(
os.getenv("CONTAINER_CPU_PERIOD", "100000")
) # CPU period (microseconds)
# Session management
MAX_CONCURRENT_SESSIONS = int(
os.getenv("MAX_CONCURRENT_SESSIONS", "3")
) # Max concurrent sessions
SESSION_TIMEOUT_MINUTES = int(
os.getenv("SESSION_TIMEOUT_MINUTES", "60")
) # Auto-cleanup timeout
# Resource monitoring thresholds
MEMORY_WARNING_THRESHOLD = float(
os.getenv("MEMORY_WARNING_THRESHOLD", "0.8")
) # 80% memory usage
CPU_WARNING_THRESHOLD = float(
os.getenv("CPU_WARNING_THRESHOLD", "0.9")
) # 90% CPU usage
# Feature flags
USE_ASYNC_DOCKER = os.getenv("USE_ASYNC_DOCKER", "true").lower() == "true"
USE_DATABASE_STORAGE = os.getenv("USE_DATABASE_STORAGE", "true").lower() == "true"

View File

@@ -129,23 +129,30 @@ class ContainerHealthMonitor:
"""Main monitoring loop.""" """Main monitoring loop."""
while self._monitoring: while self._monitoring:
try: try:
await self._perform_health_checks() await self._check_all_containers()
await self._cleanup_old_history() await self._cleanup_old_history()
except Exception as e: except Exception as e:
logger.error("Error in health monitoring loop", extra={"error": str(e)}) logger.error("Error in health monitoring loop", extra={"error": str(e)})
await asyncio.sleep(self.check_interval) await asyncio.sleep(self.check_interval)
async def _perform_health_checks(self): async def _check_all_containers(self):
"""Perform health checks on all running containers.""" """Perform health checks on all running containers."""
if not self.session_manager: if not self.session_manager:
return return
# Get all running sessions from datetime import datetime, timedelta
# Startup grace period - don't check containers that started recently
startup_grace_period = timedelta(seconds=60)
now = datetime.now()
# Get all running sessions that are past the startup grace period
running_sessions = [ running_sessions = [
session session
for session in self.session_manager.sessions.values() for session in self.session_manager.sessions.values()
if session.status == "running" if session.status == "running"
and (now - session.created_at) > startup_grace_period
] ]
if not running_sessions: if not running_sessions:
@@ -263,23 +270,30 @@ class ContainerHealthMonitor:
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]: async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
"""Get container information from Docker.""" """Get container information from Docker."""
try: try:
if self.docker_client: # Use session_manager.docker_service for consistent container access
# Try async Docker client first if (
container = await self.docker_client.get_container(container_id) self.session_manager
if hasattr(container, "_container"): and hasattr(self.session_manager, "docker_service")
return await container._container.show() and self.session_manager.docker_service
elif hasattr(container, "show"): ):
return await container.show() container_info = await self.session_manager.docker_service.get_container_info(container_id)
else: if container_info:
# Fallback to sync client if available # Convert ContainerInfo to dict format expected by health check
if ( return {
hasattr(self.session_manager, "docker_client") "State": {
and self.session_manager.docker_client "Status": container_info.status,
): "Health": {"Status": container_info.health_status} if container_info.health_status else {}
container = self.session_manager.docker_client.containers.get( }
container_id }
) elif self.docker_client and hasattr(self.docker_client, "get_container_info"):
return container.attrs container_info = await self.docker_client.get_container_info(container_id)
if container_info:
return {
"State": {
"Status": container_info.status,
"Health": {"Status": container_info.health_status} if container_info.health_status else {}
}
}
except Exception as e: except Exception as e:
logger.debug( logger.debug(
f"Failed to get container info for {container_id}", f"Failed to get container info for {container_id}",
@@ -384,8 +398,8 @@ class ContainerHealthMonitor:
# Trigger container restart through session manager # Trigger container restart through session manager
if self.session_manager: if self.session_manager:
# Create new container for the session # Restart container for the SAME session (preserves session_id)
await self.session_manager.create_session() await self.session_manager.restart_session(session_id)
logger.info( logger.info(
"Container restart initiated", "Container restart initiated",
extra={ extra={
@@ -418,17 +432,22 @@ class ContainerHealthMonitor:
async def _stop_container(self, container_id: str): async def _stop_container(self, container_id: str):
"""Stop a container.""" """Stop a container."""
try: try:
if self.docker_client: # Use session_manager.docker_service for container operations
container = await self.docker_client.get_container(container_id) # docker_service.stop_container takes container_id as a string
await self.docker_client.stop_container(container, timeout=10) if (
elif ( self.session_manager
hasattr(self.session_manager, "docker_client") and hasattr(self.session_manager, "docker_service")
and self.session_manager.docker_client and self.session_manager.docker_service
): ):
container = self.session_manager.docker_client.containers.get( await self.session_manager.docker_service.stop_container(container_id, timeout=10)
container_id elif self.docker_client and hasattr(self.docker_client, "stop_container"):
# If docker_client is docker_service, use it directly
await self.docker_client.stop_container(container_id, timeout=10)
else:
logger.warning(
"No docker client available to stop container",
extra={"container_id": container_id},
) )
container.stop(timeout=10)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Failed to stop container during restart", "Failed to stop container during restart",

View File

@@ -159,6 +159,24 @@ async def make_http_request(method: str, url: str, **kwargs) -> httpx.Response:
return await client.request(method, url, **kwargs) return await client.request(method, url, **kwargs)
@asynccontextmanager
async def stream_http_request(method: str, url: str, **kwargs):
"""Stream an HTTP response using a dedicated client with no read timeout.
Yields an httpx.Response whose body has NOT been read -- caller must
iterate over ``response.aiter_bytes()`` / ``aiter_lines()`` etc.
A separate AsyncClient is used (not the pool) because httpx's
``stream()`` keeps the connection checked-out for the lifetime of the
context manager, and SSE streams are effectively infinite. Using a
short-lived client avoids starving the pool.
"""
timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=5.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(method, url, **kwargs) as response:
yield response
async def get_connection_pool_stats() -> Dict[str, Any]: async def get_connection_pool_stats() -> Dict[str, Any]:
"""Get connection pool statistics.""" """Get connection pool statistics."""
return await _http_pool.get_pool_stats() return await _http_pool.get_pool_stats()

File diff suppressed because it is too large Load Diff

24
session-manager/models.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Data models for Session Management Service
Pydantic models for session data and API request/response schemas.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class SessionData(BaseModel):
"""Represents a user session with its associated container"""
session_id: str
container_name: str
container_id: Optional[str] = None
host_dir: str
port: Optional[int] = None
auth_token: Optional[str] = None # Authentication token for the session
created_at: datetime
last_accessed: datetime
status: str = "creating" # creating, running, stopped, error

View File

@@ -0,0 +1,6 @@
from .sessions import router as sessions_router
from .auth import router as auth_router
from .health import router as health_router
from .proxy import router as proxy_router
__all__ = ["sessions_router", "auth_router", "health_router", "proxy_router"]

View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, HTTPException
from session_manager import session_manager
from session_auth import (
get_session_auth_info as get_auth_info,
list_active_auth_sessions,
_session_token_manager,
)
router = APIRouter(tags=["auth"])
@router.get("/sessions/{session_id}/auth")
async def get_session_auth_info(session_id: str):
session = await session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
auth_info = get_auth_info(session_id)
if not auth_info:
raise HTTPException(status_code=404, detail="Authentication info not found")
return {
"session_id": session_id,
"auth_info": auth_info,
"has_token": session.auth_token is not None,
}
@router.post("/sessions/{session_id}/auth/rotate")
async def rotate_session_token(session_id: str):
session = await session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
new_token = _session_token_manager.rotate_session_token(session_id)
if not new_token:
raise HTTPException(status_code=404, detail="Failed to rotate token")
session.auth_token = new_token
session_manager._save_sessions()
return {
"session_id": session_id,
"new_token": new_token,
"message": "Token rotated successfully",
}
@router.get("/auth/sessions")
async def list_authenticated_sessions():
sessions = list_active_auth_sessions()
return {
"active_auth_sessions": len(sessions),
"sessions": sessions,
}

View File

@@ -0,0 +1,149 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException
from config import (
CONTAINER_MEMORY_LIMIT,
CONTAINER_CPU_QUOTA,
CONTAINER_CPU_PERIOD,
MAX_CONCURRENT_SESSIONS,
USE_ASYNC_DOCKER,
USE_DATABASE_STORAGE,
)
from session_manager import session_manager
from host_ip_detector import async_get_host_ip
from resource_manager import check_system_resources
from http_pool import get_connection_pool_stats
from database import get_database_stats
from container_health import get_container_health_stats, get_container_health_history
from logging_config import get_logger
logger = get_logger(__name__)
router = APIRouter(tags=["health"])
@router.get("/health/container")
async def get_container_health():
stats = get_container_health_stats()
return stats
@router.get("/health/container/{session_id}")
async def get_session_container_health(session_id: str):
session = await session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
stats = get_container_health_stats(session_id)
history = get_container_health_history(session_id, limit=20)
return {
"session_id": session_id,
"container_id": session.container_id,
"stats": stats.get(f"session_{session_id}", {}),
"recent_history": history,
}
@router.get("/health")
async def health_check():
docker_ok = False
host_ip_ok = False
detected_host_ip = None
resource_status = {}
http_pool_stats = {}
try:
docker_ok = await session_manager.docker_service.ping()
except Exception as e:
logger.warning(f"Docker health check failed: {e}")
docker_ok = False
try:
detected_host_ip = await async_get_host_ip()
host_ip_ok = True
except Exception as e:
logger.warning(f"Host IP detection failed: {e}")
host_ip_ok = False
try:
resource_status = check_system_resources()
except Exception as e:
logger.warning("Resource monitoring failed", extra={"error": str(e)})
resource_status = {"error": str(e)}
try:
http_pool_stats = await get_connection_pool_stats()
except Exception as e:
logger.warning("HTTP pool stats failed", extra={"error": str(e)})
http_pool_stats = {"error": str(e)}
database_status = {}
if USE_DATABASE_STORAGE:
try:
database_status = await get_database_stats()
except Exception as e:
logger.warning("Database stats failed", extra={"error": str(e)})
database_status = {"status": "error", "error": str(e)}
container_health_stats = {}
try:
container_health_stats = get_container_health_stats()
except Exception as e:
logger.warning("Container health stats failed", extra={"error": str(e)})
container_health_stats = {"error": str(e)}
resource_alerts = (
resource_status.get("alerts", []) if isinstance(resource_status, dict) else []
)
critical_alerts = [
a
for a in resource_alerts
if isinstance(a, dict) and a.get("level") == "critical"
]
http_healthy = (
http_pool_stats.get("status") == "healthy"
if isinstance(http_pool_stats, dict)
else False
)
if critical_alerts or not (docker_ok and host_ip_ok and http_healthy):
status = "unhealthy"
elif resource_alerts:
status = "degraded"
else:
status = "healthy"
health_data = {
"status": status,
"docker": docker_ok,
"docker_mode": "async" if USE_ASYNC_DOCKER else "sync",
"host_ip_detection": host_ip_ok,
"detected_host_ip": detected_host_ip,
"http_connection_pool": http_pool_stats,
"storage_backend": "database" if USE_DATABASE_STORAGE else "json_file",
"active_sessions": len(
[s for s in session_manager.sessions.values() if s.status == "running"]
),
"resource_limits": {
"memory_limit": CONTAINER_MEMORY_LIMIT,
"cpu_quota": CONTAINER_CPU_QUOTA,
"cpu_period": CONTAINER_CPU_PERIOD,
"max_concurrent_sessions": MAX_CONCURRENT_SESSIONS,
},
"system_resources": resource_status.get("system_resources", {})
if isinstance(resource_status, dict)
else {},
"resource_alerts": resource_alerts,
"timestamp": datetime.now().isoformat(),
}
if USE_DATABASE_STORAGE and database_status:
health_data["database"] = database_status
if container_health_stats:
health_data["container_health"] = container_health_stats
return health_data

View File

@@ -0,0 +1,216 @@
import os
import time
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request, Response
from starlette.responses import RedirectResponse, StreamingResponse
import httpx
from session_manager import session_manager
from http_pool import make_http_request, stream_http_request
from logging_config import (
RequestContext,
log_request,
log_session_operation,
log_security_event,
)
router = APIRouter(tags=["proxy"])
ALL_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
def get_session_from_cookie(request: Request) -> str:
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(
status_code=400,
detail="No active session - please access via /c/{id} first",
)
return session_id
@router.get("/c/{session_id}")
@router.get("/c/{session_id}/{path:path}")
async def enter_session(request: Request, session_id: str, path: str = ""):
"""Entry point: set session cookie and redirect to root."""
session = await session_manager.get_session(session_id)
if not session or session.status != "running":
raise HTTPException(
status_code=404, detail="Session not found or not running"
)
resp = RedirectResponse(url="/", status_code=302)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp
def _is_sse_request(request: Request, path: str) -> bool:
"""Detect SSE requests by Accept header or path convention."""
accept = request.headers.get("accept", "")
if "text/event-stream" in accept:
return True
# OpenCode uses /global/event and /event paths for SSE
if path == "event" or path.endswith("/event"):
return True
return False
@router.api_route("/{path:path}", methods=ALL_METHODS)
async def proxy_root_to_container(request: Request, path: str):
"""Catch-all: proxy everything to the container identified by cookie."""
session_id = get_session_from_cookie(request)
return await _proxy_to_container(request, session_id, path)
async def _proxy_to_container(request: Request, session_id: str, path: str):
start_time = time.time()
with RequestContext():
session = await session_manager.get_session(session_id)
if not session or session.status != "running":
raise HTTPException(
status_code=404, detail="Session not found or not running"
)
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
parsed = urlparse(docker_host)
container_host = parsed.hostname or "docker-daemon"
container_url = f"http://{container_host}:{session.port}"
url = f"{container_url}/{path}"
if request.url.query:
url += f"?{request.url.query}"
body = await request.body()
headers = dict(request.headers)
headers.pop("host", None)
if session.auth_token:
headers["Authorization"] = f"Bearer {session.auth_token}"
headers["X-Session-Token"] = session.auth_token
headers["X-Session-ID"] = session.session_id
# --- SSE streaming path ---
if _is_sse_request(request, path):
return await _proxy_sse(request, session_id, path, url, headers, body, start_time)
# --- Buffered path ---
try:
log_session_operation(
session_id, "proxy_request", method=request.method, path=path
)
response = await make_http_request(
method=request.method,
url=url,
headers=headers,
content=body,
)
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/{path}",
response.status_code,
duration_ms,
session_id=session_id,
operation="proxy_complete",
)
resp = Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp
except httpx.TimeoutException as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method, f"/{path}", 504, duration_ms,
session_id=session_id, error="timeout",
)
raise HTTPException(
status_code=504, detail="Request to session container timed out"
)
except httpx.RequestError as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method, f"/{path}", 502, duration_ms,
session_id=session_id, error=str(e),
)
raise HTTPException(
status_code=502,
detail=f"Failed to connect to session container: {str(e)}",
)
async def _proxy_sse(
request: Request,
session_id: str,
path: str,
url: str,
headers: dict,
body: bytes,
start_time: float,
):
"""Proxy an SSE event stream without buffering."""
log_session_operation(
session_id, "proxy_sse_stream", method=request.method, path=path
)
async def event_generator():
try:
async with stream_http_request(
method=request.method,
url=url,
headers=headers,
content=body,
) as upstream:
async for chunk in upstream.aiter_bytes():
yield chunk
except httpx.RequestError as e:
log_security_event(
"proxy_sse_error", "error",
session_id=session_id, method=request.method,
path=path, error=str(e),
)
finally:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method, f"/{path}", 200, duration_ms,
session_id=session_id, operation="proxy_sse_complete",
)
resp = StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp

View File

@@ -0,0 +1,121 @@
import time
from typing import List
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
from models import SessionData
from session_manager import session_manager
from session_auth import revoke_session_auth_token
from logging_config import (
RequestContext,
log_performance,
log_request,
log_session_operation,
)
router = APIRouter(tags=["sessions"])
@router.post("/sessions", response_model=SessionData)
async def create_session(request: Request):
start_time = time.time()
with RequestContext():
try:
log_request("POST", "/sessions", 200, 0, operation="create_session_start")
session = await session_manager.create_session()
duration_ms = (time.time() - start_time) * 1000
log_session_operation(
session.session_id, "created", duration_ms=duration_ms
)
log_performance(
"create_session", duration_ms, session_id=session.session_id
)
return session
except HTTPException as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
"POST", "/sessions", e.status_code, duration_ms, error=str(e.detail)
)
raise
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
log_request("POST", "/sessions", 500, duration_ms, error=str(e))
raise HTTPException(
status_code=500, detail=f"Failed to create session: {str(e)}"
)
@router.get("/sessions/{session_id}", response_model=SessionData)
async def get_session(session_id: str, request: Request):
start_time = time.time()
with RequestContext():
try:
log_request(
"GET", f"/sessions/{session_id}", 200, 0, operation="get_session_start"
)
session = await session_manager.get_session(session_id)
if not session:
duration_ms = (time.time() - start_time) * 1000
log_request(
"GET",
f"/sessions/{session_id}",
404,
duration_ms,
session_id=session_id,
)
raise HTTPException(status_code=404, detail="Session not found")
duration_ms = (time.time() - start_time) * 1000
log_request(
"GET",
f"/sessions/{session_id}",
200,
duration_ms,
session_id=session_id,
)
log_session_operation(session_id, "accessed", duration_ms=duration_ms)
return session
except HTTPException:
raise
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
"GET", f"/sessions/{session_id}", 500, duration_ms, error=str(e)
)
raise HTTPException(
status_code=500, detail=f"Failed to get session: {str(e)}"
)
@router.get("/sessions", response_model=List[SessionData])
async def list_sessions():
return await session_manager.list_sessions()
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, background_tasks: BackgroundTasks):
session = await session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
revoke_session_auth_token(session_id)
background_tasks.add_task(session_manager.cleanup_expired_sessions)
del session_manager.sessions[session_id]
session_manager._save_sessions()
return {"message": f"Session {session_id} scheduled for deletion"}
@router.post("/cleanup")
async def trigger_cleanup():
await session_manager.cleanup_expired_sessions()
return {"message": "Cleanup completed"}

View File

@@ -83,8 +83,8 @@ class SessionTokenManager:
session_data = self._session_tokens[session_id] session_data = self._session_tokens[session_id]
# Check if token matches # Check if token matches using constant-time comparison to prevent timing attacks
if session_data["token"] != token: if not secrets.compare_digest(session_data["token"], token):
return False, "Invalid token" return False, "Invalid token"
# Check if token has expired # Check if token has expired
@@ -212,7 +212,7 @@ def revoke_session_auth_token(session_id: str) -> bool:
def rotate_session_auth_token(session_id: str) -> Optional[str]: def rotate_session_auth_token(session_id: str) -> Optional[str]:
"""Rotate a session authentication token.""" """Rotate a session authentication token."""
return _session_token_manager.rotate_session_auth_token(session_id) return _session_token_manager.rotate_session_token(session_id)
def cleanup_expired_auth_tokens() -> int: def cleanup_expired_auth_tokens() -> int:

View File

@@ -0,0 +1,492 @@
import os
import uuid
import json
import asyncio
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Optional, List
from fastapi import HTTPException
from config import (
SESSIONS_DIR,
SESSIONS_FILE,
CONTAINER_IMAGE,
MAX_CONCURRENT_SESSIONS,
SESSION_TIMEOUT_MINUTES,
USE_ASYNC_DOCKER,
USE_DATABASE_STORAGE,
)
from models import SessionData
from docker_service import DockerService
from database import SessionModel
from resource_manager import get_resource_limits, should_throttle_sessions
from session_auth import generate_session_auth_token, cleanup_expired_auth_tokens
from logging_config import get_logger
logger = get_logger(__name__)
class SessionManager:
def __init__(self, docker_service: Optional[DockerService] = None):
if docker_service:
self.docker_service = docker_service
else:
self.docker_service = DockerService(use_async=USE_ASYNC_DOCKER)
if USE_DATABASE_STORAGE:
self.sessions: Dict[str, SessionData] = {}
logger.info("Session storage initialized", extra={"backend": "database"})
else:
self.sessions: Dict[str, SessionData] = {}
self._load_sessions_from_file()
logger.info("Session storage initialized", extra={"backend": "json_file"})
from container_health import get_container_health_monitor
self.health_monitor = get_container_health_monitor()
logger.info(
"SessionManager initialized",
extra={
"docker_service_type": type(self.docker_service).__name__,
"storage_backend": "database" if USE_DATABASE_STORAGE else "json_file",
},
)
def _load_sessions_from_file(self):
if SESSIONS_FILE.exists():
try:
with open(SESSIONS_FILE, "r") as f:
data = json.load(f)
for session_id, session_dict in data.items():
session_dict["created_at"] = datetime.fromisoformat(
session_dict["created_at"]
)
session_dict["last_accessed"] = datetime.fromisoformat(
session_dict["last_accessed"]
)
self.sessions[session_id] = SessionData(**session_dict)
logger.info(
"Sessions loaded from JSON file",
extra={"count": len(self.sessions)},
)
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Could not load sessions file", extra={"error": str(e)})
self.sessions = {}
async def _load_sessions_from_database(self):
try:
db_sessions = await SessionModel.get_sessions_by_status("running")
db_sessions.extend(await SessionModel.get_sessions_by_status("creating"))
self.sessions = {}
for session_dict in db_sessions:
session_data = SessionData(**session_dict)
self.sessions[session_dict["session_id"]] = session_data
logger.info(
"Sessions loaded from database", extra={"count": len(self.sessions)}
)
except Exception as e:
logger.error(
"Failed to load sessions from database", extra={"error": str(e)}
)
self.sessions = {}
def _save_sessions(self):
SESSIONS_DIR.mkdir(exist_ok=True)
data = {}
for session_id, session in self.sessions.items():
data[session_id] = session.dict()
with open(SESSIONS_FILE, "w") as f:
json.dump(data, f, indent=2, default=str)
def _generate_session_id(self) -> str:
return str(uuid.uuid4()).replace("-", "")[:16]
def _get_available_port(self) -> int:
used_ports = {s.port for s in self.sessions.values() if s.port}
port = 8081
while port in used_ports:
port += 1
return port
def _check_container_limits(self) -> bool:
active_sessions = sum(
1 for s in self.sessions.values() if s.status in ["creating", "running"]
)
return active_sessions < MAX_CONCURRENT_SESSIONS
async def _async_check_container_limits(self) -> bool:
return self._check_container_limits()
async def create_session(self) -> SessionData:
if USE_ASYNC_DOCKER:
limits_ok = await self._async_check_container_limits()
else:
limits_ok = self._check_container_limits()
if not limits_ok:
raise HTTPException(
status_code=429,
detail=f"Maximum concurrent sessions ({MAX_CONCURRENT_SESSIONS}) reached",
)
should_throttle, reason = should_throttle_sessions()
if should_throttle:
raise HTTPException(
status_code=503,
detail=f"System resource constraints prevent new sessions: {reason}",
)
session_id = self._generate_session_id()
container_name = f"opencode-{session_id}"
host_dir = str(SESSIONS_DIR / session_id)
port = self._get_available_port()
Path(host_dir).mkdir(parents=True, exist_ok=True)
auth_token = generate_session_auth_token(session_id)
session = SessionData(
session_id=session_id,
container_name=container_name,
host_dir=host_dir,
port=port,
auth_token=auth_token,
created_at=datetime.now(),
last_accessed=datetime.now(),
status="creating",
)
self.sessions[session_id] = session
if USE_DATABASE_STORAGE:
try:
await SessionModel.create_session(
{
"session_id": session_id,
"container_name": container_name,
"host_dir": host_dir,
"port": port,
"auth_token": auth_token,
"status": "creating",
}
)
logger.info(
"Session created in database", extra={"session_id": session_id}
)
except Exception as e:
logger.error(
"Failed to create session in database",
extra={"session_id": session_id, "error": str(e)},
)
if USE_ASYNC_DOCKER:
asyncio.create_task(self._start_container_async(session))
else:
asyncio.create_task(self._start_container_sync(session))
return session
async def _start_container_async(self, session: SessionData):
try:
resource_limits = get_resource_limits()
logger.info(
f"Starting container {session.container_name} with resource limits: "
f"memory={resource_limits.memory_limit}, cpu_quota={resource_limits.cpu_quota}"
)
container_info = await self.docker_service.create_container(
name=session.container_name,
image=CONTAINER_IMAGE,
volumes={session.host_dir: {"bind": "/app/somedir", "mode": "rw"}},
ports={"8080": session.port},
environment={
"MCP_SERVER": os.getenv("MCP_SERVER", ""),
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
"SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id,
},
network_mode="bridge",
mem_limit=resource_limits.memory_limit,
cpu_quota=resource_limits.cpu_quota,
cpu_period=resource_limits.cpu_period,
tmpfs={
"/tmp": "rw,noexec,nosuid,size=100m",
"/var/tmp": "rw,noexec,nosuid,size=50m",
},
)
await self.docker_service.start_container(container_info.container_id)
session.container_id = container_info.container_id
session.status = "running"
self.sessions[session.session_id] = session
if USE_DATABASE_STORAGE:
try:
await SessionModel.update_session(
session.session_id,
{
"container_id": container_info.container_id,
"status": "running",
},
)
except Exception as e:
logger.error(
"Failed to update session in database",
extra={"session_id": session.session_id, "error": str(e)},
)
logger.info(
"Container started successfully",
extra={
"session_id": session.session_id,
"container_name": session.container_name,
"container_id": container_info.container_id,
"port": session.port,
},
)
except Exception as e:
session.status = "error"
self._save_sessions()
logger.error(f"Failed to start container {session.container_name}: {e}")
async def _start_container_sync(self, session: SessionData):
try:
resource_limits = get_resource_limits()
logger.info(
f"Starting container {session.container_name} with resource limits: "
f"memory={resource_limits.memory_limit}, cpu_quota={resource_limits.cpu_quota}"
)
container_info = await self.docker_service.create_container(
name=session.container_name,
image=CONTAINER_IMAGE,
volumes={session.host_dir: {"bind": "/app/somedir", "mode": "rw"}},
ports={"8080": session.port},
environment={
"MCP_SERVER": os.getenv("MCP_SERVER", ""),
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
"SESSION_AUTH_TOKEN": session.auth_token or "",
"SESSION_ID": session.session_id,
},
network_mode="bridge",
mem_limit=resource_limits.memory_limit,
cpu_quota=resource_limits.cpu_quota,
cpu_period=resource_limits.cpu_period,
tmpfs={
"/tmp": "rw,noexec,nosuid,size=100m",
"/var/tmp": "rw,noexec,nosuid,size=50m",
},
)
session.container_id = container_info.container_id
session.status = "running"
self.sessions[session.session_id] = session
if USE_DATABASE_STORAGE:
try:
await SessionModel.update_session(
session.session_id,
{
"container_id": container_info.container_id,
"status": "running",
},
)
except Exception as e:
logger.error(
"Failed to update session in database",
extra={"session_id": session.session_id, "error": str(e)},
)
logger.info(
"Container started successfully",
extra={
"session_id": session.session_id,
"container_name": session.container_name,
"container_id": container_info.container_id,
"port": session.port,
},
)
except Exception as e:
session.status = "error"
self._save_sessions()
logger.error(f"Failed to start container {session.container_name}: {e}")
async def get_session(self, session_id: str) -> Optional[SessionData]:
session = self.sessions.get(session_id)
if session:
session.last_accessed = datetime.now()
if USE_DATABASE_STORAGE:
try:
await SessionModel.update_session(
session_id, {"last_accessed": datetime.now()}
)
except Exception as e:
logger.warning(
"Failed to update session access time in database",
extra={"session_id": session_id, "error": str(e)},
)
return session
if USE_DATABASE_STORAGE:
try:
db_session = await SessionModel.get_session(session_id)
if db_session:
session_data = SessionData(**db_session)
self.sessions[session_id] = session_data
logger.debug(
"Session loaded from database", extra={"session_id": session_id}
)
return session_data
except Exception as e:
logger.error(
"Failed to load session from database",
extra={"session_id": session_id, "error": str(e)},
)
return None
async def list_sessions(self) -> List[SessionData]:
return list(self.sessions.values())
async def restart_session(self, session_id: str) -> Optional[SessionData]:
"""Restart a session's container while preserving the session ID.
Unlike create_session(), this reuses the existing session data
and only creates a new container, maintaining session ID continuity.
This method removes the old container to free up the port.
"""
session = await self.get_session(session_id)
if not session:
logger.error(
"Cannot restart session: not found",
extra={"session_id": session_id},
)
return None
old_container_id = session.container_id
logger.info(
"Restarting session container",
extra={"session_id": session_id, "old_container_id": old_container_id},
)
# Stop and remove old container to free up the port
if old_container_id and self.docker_service:
try:
logger.info(
"Stopping old container for restart",
extra={"session_id": session_id, "container_id": old_container_id},
)
await self.docker_service.stop_container(old_container_id)
except Exception as e:
logger.warning(
"Failed to stop old container (may already be stopped)",
extra={"session_id": session_id, "container_id": old_container_id, "error": str(e)},
)
try:
logger.info(
"Removing old container for restart",
extra={"session_id": session_id, "container_id": old_container_id},
)
await self.docker_service.remove_container(old_container_id, force=True)
except Exception as e:
logger.warning(
"Failed to remove old container",
extra={"session_id": session_id, "container_id": old_container_id, "error": str(e)},
)
# Generate new container name for the restart
new_container_name = f"opencode-{session_id}-{uuid.uuid4().hex[:8]}"
session.container_name = new_container_name
session.container_id = None # Clear old container_id
session.status = "starting"
# Update session in store before starting container
self.sessions[session_id] = session
if USE_DATABASE_STORAGE:
try:
await SessionModel.update_session(
session_id,
{
"container_name": new_container_name,
"container_id": None,
"status": "starting",
},
)
except Exception as e:
logger.error(
"Failed to update session in database during restart",
extra={"session_id": session_id, "error": str(e)},
)
# Start new container for this session
if USE_ASYNC_DOCKER:
asyncio.create_task(self._start_container_async(session))
else:
asyncio.create_task(self._start_container_sync(session))
return session
async def list_containers_async(self, all: bool = False) -> List:
return await self.docker_service.list_containers(all=all)
async def cleanup_expired_sessions(self):
now = datetime.now()
expired_sessions = []
for session_id, session in self.sessions.items():
if now - session.last_accessed > timedelta(minutes=SESSION_TIMEOUT_MINUTES):
expired_sessions.append(session_id)
try:
await self.docker_service.stop_container(
session.container_name, timeout=10
)
await self.docker_service.remove_container(session.container_name)
logger.info(f"Cleaned up container {session.container_name}")
except Exception as e:
logger.error(
f"Error cleaning up container {session.container_name}: {e}"
)
try:
shutil.rmtree(session.host_dir)
logger.info(f"Removed session directory {session.host_dir}")
except OSError as e:
logger.error(
f"Error removing session directory {session.host_dir}: {e}"
)
for session_id in expired_sessions:
del self.sessions[session_id]
if expired_sessions:
self._save_sessions()
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
expired_tokens = cleanup_expired_auth_tokens()
if expired_tokens > 0:
logger.info(f"Cleaned up {expired_tokens} expired authentication tokens")
session_manager = SessionManager()