|
|
|
|
@@ -6,6 +6,7 @@ Each session gets its own isolated container with a dedicated working directory.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import uuid
|
|
|
|
|
import json
|
|
|
|
|
import asyncio
|
|
|
|
|
@@ -15,6 +16,7 @@ from datetime import datetime, timedelta
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Dict, Optional, List
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
import docker
|
|
|
|
|
from docker.errors import DockerException, NotFound
|
|
|
|
|
@@ -358,7 +360,7 @@ class SessionManager:
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
async def _start_container_async(self, session: SessionData):
|
|
|
|
|
"""Start the OpenCode container asynchronously using aiodeocker"""
|
|
|
|
|
"""Start the OpenCode container asynchronously using aiodocker"""
|
|
|
|
|
try:
|
|
|
|
|
# Get and validate resource limits
|
|
|
|
|
resource_limits = get_resource_limits()
|
|
|
|
|
@@ -378,8 +380,8 @@ class SessionManager:
|
|
|
|
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
|
|
|
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
|
|
|
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
|
|
|
|
# Secure authentication for OpenCode server
|
|
|
|
|
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
|
|
|
|
# Auth disabled for development - will be added later
|
|
|
|
|
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
|
|
|
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
|
|
|
|
"SESSION_ID": session.session_id,
|
|
|
|
|
},
|
|
|
|
|
@@ -395,10 +397,8 @@ class SessionManager:
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# For async mode, containers are already started during creation
|
|
|
|
|
# For sync mode, we need to explicitly start them
|
|
|
|
|
if not self.docker_service.use_async:
|
|
|
|
|
await self.docker_service.start_container(container_info.container_id)
|
|
|
|
|
# Containers need to be explicitly started after creation
|
|
|
|
|
await self.docker_service.start_container(container_info.container_id)
|
|
|
|
|
|
|
|
|
|
session.container_id = container_info.container_id
|
|
|
|
|
session.status = "running"
|
|
|
|
|
@@ -458,8 +458,8 @@ class SessionManager:
|
|
|
|
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
|
|
|
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
|
|
|
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
|
|
|
|
# Secure authentication for OpenCode server
|
|
|
|
|
"OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
|
|
|
|
# Auth disabled for development - will be added later
|
|
|
|
|
# "OPENCODE_SERVER_PASSWORD": session.auth_token or "",
|
|
|
|
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
|
|
|
|
"SESSION_ID": session.session_id,
|
|
|
|
|
},
|
|
|
|
|
@@ -883,6 +883,118 @@ async def get_session_container_health(session_id: str):
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Routes for OpenCode SPA runtime requests
|
|
|
|
|
# These paths are hardcoded in the compiled JS and need cookie-based session routing
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/global/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_global_to_session(request: Request, path: str):
|
|
|
|
|
"""Proxy /global/* requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
# Redirect to session-prefixed path
|
|
|
|
|
return await proxy_to_session(request, session_id, f"global/{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/assets/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_assets_to_session(request: Request, path: str):
|
|
|
|
|
"""Proxy /assets/* requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
# Redirect to session-prefixed path
|
|
|
|
|
return await proxy_to_session(request, session_id, f"assets/{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/provider/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_provider_path_to_session(request: Request, path: str):
|
|
|
|
|
"""Proxy /provider/* requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, f"provider/{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/provider",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_provider_to_session(request: Request):
|
|
|
|
|
"""Proxy /provider requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, "provider")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/project",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_project_to_session(request: Request):
|
|
|
|
|
"""Proxy /project requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, "project")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/path",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_path_to_session(request: Request):
|
|
|
|
|
"""Proxy /path requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, "path")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/find/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_find_to_session(request: Request, path: str):
|
|
|
|
|
"""Proxy /find/* requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, f"find/{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/file",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_file_to_session(request: Request):
|
|
|
|
|
"""Proxy /file requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, "file")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/file/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
)
|
|
|
|
|
async def proxy_file_path_to_session(request: Request, path: str):
|
|
|
|
|
"""Proxy /file/* requests to the current session based on cookie"""
|
|
|
|
|
session_id = request.cookies.get("lovdata_session")
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No active session - please access via /session/{id}/ first")
|
|
|
|
|
return await proxy_to_session(request, session_id, f"file/{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.api_route(
|
|
|
|
|
"/session/{session_id}/{path:path}",
|
|
|
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
|
|
|
@@ -916,40 +1028,14 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|
|
|
|
status_code=404, detail="Session not found or not running"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Dynamically detect the Docker host IP from container perspective
|
|
|
|
|
# This supports multiple Docker environments (Docker Desktop, Linux, cloud, etc.)
|
|
|
|
|
try:
|
|
|
|
|
host_ip = await async_get_host_ip()
|
|
|
|
|
logger.info(f"Using detected host IP for proxy: {host_ip}")
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
# Fallback to environment variable or common defaults
|
|
|
|
|
host_ip = os.getenv("HOST_IP")
|
|
|
|
|
if not host_ip:
|
|
|
|
|
# Try common Docker gateway IPs as final fallback
|
|
|
|
|
common_gateways = ["172.17.0.1", "192.168.65.1", "host.docker.internal"]
|
|
|
|
|
for gateway in common_gateways:
|
|
|
|
|
try:
|
|
|
|
|
# Test connectivity to gateway
|
|
|
|
|
import socket
|
|
|
|
|
|
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
sock.settimeout(1.0)
|
|
|
|
|
result = sock.connect_ex((gateway, 22))
|
|
|
|
|
sock.close()
|
|
|
|
|
if result == 0:
|
|
|
|
|
host_ip = gateway
|
|
|
|
|
logger.warning(f"Using fallback gateway IP: {host_ip}")
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
else:
|
|
|
|
|
logger.error(f"Host IP detection failed: {e}")
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail="Could not determine Docker host IP for proxy routing",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
container_url = f"http://{host_ip}:{session.port}"
|
|
|
|
|
# For DinD architecture: containers run inside docker-daemon with ports mapped
|
|
|
|
|
# to docker-daemon's interface, so we proxy through docker-daemon hostname
|
|
|
|
|
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
|
|
|
|
|
# Extract hostname from DOCKER_HOST (e.g., "http://docker-daemon:2375" -> "docker-daemon")
|
|
|
|
|
parsed = urlparse(docker_host)
|
|
|
|
|
container_host = parsed.hostname or "docker-daemon"
|
|
|
|
|
|
|
|
|
|
container_url = f"http://{container_host}:{session.port}"
|
|
|
|
|
|
|
|
|
|
# Prepare the request URL
|
|
|
|
|
url = f"{container_url}/{path}"
|
|
|
|
|
@@ -1002,12 +1088,46 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Return the response
|
|
|
|
|
return Response(
|
|
|
|
|
content=response.content,
|
|
|
|
|
# Return the response - inject base tag for HTML to fix asset paths
|
|
|
|
|
content = response.content
|
|
|
|
|
response_headers = dict(response.headers)
|
|
|
|
|
content_type = response.headers.get("content-type", "")
|
|
|
|
|
|
|
|
|
|
# For HTML responses, rewrite root-relative paths to include session prefix
|
|
|
|
|
# OpenCode uses paths like /assets/, /favicon.ico which bypass <base> tag
|
|
|
|
|
# We need to prepend the session path to make them work
|
|
|
|
|
if "text/html" in content_type:
|
|
|
|
|
try:
|
|
|
|
|
html = content.decode("utf-8")
|
|
|
|
|
session_prefix = f"/session/{session_id}"
|
|
|
|
|
|
|
|
|
|
# Rewrite src="/..." to src="/session/{id}/..."
|
|
|
|
|
html = re.sub(r'src="/', f'src="{session_prefix}/', html)
|
|
|
|
|
# Rewrite href="/..." to href="/session/{id}/..."
|
|
|
|
|
html = re.sub(r'href="/', f'href="{session_prefix}/', html)
|
|
|
|
|
# Rewrite content="/..." for meta tags
|
|
|
|
|
html = re.sub(r'content="/', f'content="{session_prefix}/', html)
|
|
|
|
|
|
|
|
|
|
content = html.encode("utf-8")
|
|
|
|
|
response_headers["content-length"] = str(len(content))
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
pass # Not valid UTF-8, skip rewriting
|
|
|
|
|
|
|
|
|
|
# Create response with session tracking cookie
|
|
|
|
|
resp = Response(
|
|
|
|
|
content=content,
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
headers=dict(response.headers),
|
|
|
|
|
headers=response_headers,
|
|
|
|
|
)
|
|
|
|
|
# Set cookie to track current session for /global/* and /assets/* routing
|
|
|
|
|
resp.set_cookie(
|
|
|
|
|
key="lovdata_session",
|
|
|
|
|
value=session_id,
|
|
|
|
|
httponly=True,
|
|
|
|
|
samesite="lax",
|
|
|
|
|
max_age=86400, # 24 hours
|
|
|
|
|
)
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
except httpx.TimeoutException as e:
|
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
|
|