feat(instance-proxy): add HTTP proxy for tool instances
Add API proxy endpoint so users can access running tool instances
through the backend API instead of internal Docker network.
Backend:
- Add container_name field to ToolInstance model
- Create /instances/{id}/proxy/{path:path} endpoint with ownership checks
- Proxy HTTP requests to containers via docker network using container names
- Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
- Store proxy URL in instance.url instead of localhost
- Add Alembic migration 0007 for container_name column
- Add get_container_name() utility to docker.py
Frontend:
- Update Open button to use full proxy URL (API_BASE_URL + instance.url)
Closes instance-proxy OpenSpec change.
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""add container_name to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0007_instance_container_name
|
||||||
|
Revises: 0006_tool_instances
|
||||||
|
Create Date: 2026-05-20 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0007_instance_container_name"
|
||||||
|
down_revision: Union[str, None] = "0006_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "container_name")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Instance proxy router for forwarding HTTP requests to running containers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy_request(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy an HTTP request to a running instance."""
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="instance is not running",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build target URL
|
||||||
|
target_url = f"http://{instance.container_name}:{instance.port}"
|
||||||
|
if path:
|
||||||
|
target_url += f"/{path}"
|
||||||
|
|
||||||
|
# Get query string
|
||||||
|
query_string = str(request.query_params)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Forward headers (excluding host and cookies)
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
if key.lower() not in ("host", "cookie", "content-length"):
|
||||||
|
headers[key] = value
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
body = await request.body()
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Proxy error to %s: %s", target_url, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"failed to reach instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
# Remove hop-by-hop headers
|
||||||
|
for header in ("content-encoding", "transfer-encoding", "connection"):
|
||||||
|
response_headers.pop(header, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instance_id}/proxy/{path:path}")
|
||||||
|
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
|
||||||
|
async def proxy_to_instance(
|
||||||
|
request: Request,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy requests to a running tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
path: The path to proxy to the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the proxied instance.
|
||||||
|
"""
|
||||||
|
return await _proxy_request(request, instance_id, path, user_id, session)
|
||||||
@@ -5,13 +5,15 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id
|
from src.auth.dependencies import get_current_user_id
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
@@ -24,6 +26,7 @@ from src.services.docker import (
|
|||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
|
get_container_name,
|
||||||
get_container_logs,
|
get_container_logs,
|
||||||
get_container_status,
|
get_container_status,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
@@ -350,14 +353,18 @@ async def start_instance(
|
|||||||
detail=f"failed to start instance: {stderr}",
|
detail=f"failed to start instance: {stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get container ID
|
# Get container ID and name
|
||||||
container_id = get_container_id(instance.name)
|
container_id = get_container_id(instance.name)
|
||||||
if container_id:
|
if container_id:
|
||||||
instance.container_id = container_id
|
instance.container_id = container_id
|
||||||
|
|
||||||
|
container_name = get_container_name(instance.name)
|
||||||
|
if container_name:
|
||||||
|
instance.container_name = container_name
|
||||||
|
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
instance.url = f"http://localhost:{instance.port}"
|
instance.url = f"/instances/{instance.id}/proxy/"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
@@ -547,6 +554,136 @@ async def get_instance_logs(
|
|||||||
return {"logs": logs}
|
return {"logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
)
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.put(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.patch(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.head(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
@router.options(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
|
summary="Proxy to instance",
|
||||||
|
description="Proxy HTTP requests to a running tool instance.",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def proxy_to_instance(
|
||||||
|
request: Request,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Proxy requests to a running tool instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the instance.
|
||||||
|
path: The path to proxy to the instance.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from the proxied instance.
|
||||||
|
"""
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if instance.owner_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="not authorized to access this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="instance is not running",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build target URL
|
||||||
|
target_url = f"http://{instance.container_name}:{instance.port}"
|
||||||
|
if path:
|
||||||
|
target_url += f"/{path}"
|
||||||
|
|
||||||
|
# Get query string
|
||||||
|
query_string = str(request.query_params)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
# Forward headers (excluding host)
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers.pop("host", None)
|
||||||
|
headers.pop("cookie", None) # Don't forward session cookies
|
||||||
|
|
||||||
|
# Forward the request
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
body = await request.body()
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Proxy error: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"failed to reach instance: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response_headers = dict(response.headers)
|
||||||
|
# Remove hop-by-hop headers
|
||||||
|
for header in ["content-encoding", "transfer-encoding", "connection"]:
|
||||||
|
response_headers.pop(header, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from fastapi import APIRouter as FastAPIRouter
|
from fastapi import APIRouter as FastAPIRouter
|
||||||
|
|
||||||
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.api.health import router as health_router
|
|||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
@@ -184,5 +185,6 @@ app.include_router(user_config_router)
|
|||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
container_id: Mapped[str | None] = mapped_column(
|
container_id: Mapped[str | None] = mapped_column(
|
||||||
String(255), nullable=True
|
String(255), nullable=True
|
||||||
)
|
)
|
||||||
|
container_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
compose_path: Mapped[str | None] = mapped_column(
|
compose_path: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
String(1024), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -113,6 +113,26 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container name or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
def get_container_status(container_id: str) -> str:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
interface InstanceListProps {
|
interface InstanceListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
@@ -147,7 +149,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && (
|
{instance.status === "running" && instance.url && (
|
||||||
<a
|
<a
|
||||||
href={instance.url}
|
href={`${API_BASE_URL}${instance.url}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-20
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool instances run as Docker containers on the internal Docker network. The backend stores their URL as `http://localhost:{port}`, which is only accessible from inside the API container. Users clicking "Open" in the frontend get a 404 because their browser can't reach the internal container.
|
||||||
|
|
||||||
|
The API and containers share a Docker network, so the API can reach containers by their container name or IP.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Users can access running tool instances through the API via HTTPS
|
||||||
|
- Proxy enforces ownership (only instance owner can access)
|
||||||
|
- Support both HTTP and WebSocket traffic
|
||||||
|
- Minimal latency overhead
|
||||||
|
- Works with existing Docker setup
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Public URLs / custom domains for instances (that's Option 2/3)
|
||||||
|
- Load balancing across multiple instances
|
||||||
|
- Advanced path rewriting (just pass-through)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Proxy via FastAPI route (not separate service)
|
||||||
|
|
||||||
|
**Decision:** Implement proxying as a FastAPI endpoint using `httpx` for async forwarding.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Keeps everything in one deployable unit
|
||||||
|
- Easy access to existing auth dependencies (`get_current_user_id`)
|
||||||
|
- Can reuse existing session cookie auth
|
||||||
|
- No extra infrastructure needed
|
||||||
|
|
||||||
|
**Alternative considered:** Separate nginx/traefik proxy service
|
||||||
|
- Rejected: adds operational complexity for a single feature
|
||||||
|
|
||||||
|
### Use container name for internal routing
|
||||||
|
|
||||||
|
**Decision:** Store container name in ToolInstance model and route to `http://{container_name}:{port}`
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Container names are stable and DNS-resolvable within Docker network
|
||||||
|
- More reliable than IPs which can change
|
||||||
|
- Already using container names in docker.py
|
||||||
|
|
||||||
|
### Path: `/instances/{id}/proxy/{path:path}`
|
||||||
|
|
||||||
|
**Decision:** All proxied traffic goes through `/instances/{id}/proxy/*`
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Clear URL structure
|
||||||
|
- Easy to apply auth middleware
|
||||||
|
- `path:path` captures everything after `/proxy/`
|
||||||
|
|
||||||
|
### WebSocket upgrade handling
|
||||||
|
|
||||||
|
**Decision:** Support WebSocket upgrade by inspecting the `Upgrade: websocket` header and establishing a bidirectional pipe.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- code-server and jupyter use WebSockets for real-time features
|
||||||
|
- FastAPI doesn't natively support proxying WebSockets, but we can use `starlette.websockets` to handle the upgrade
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk]** API becomes bandwidth bottleneck for all instance traffic
|
||||||
|
→ **Mitigation:** Document this limitation. Future migration to Option 2 (Traefik labels) possible.
|
||||||
|
|
||||||
|
**[Risk]** Container name collision
|
||||||
|
→ **Mitigation:** Instance names already include UUID suffix, collision probability is negligible.
|
||||||
|
|
||||||
|
**[Risk]** Large file uploads/downloads through proxy
|
||||||
|
→ **Mitigation:** Use streaming response in httpx. Monitor memory usage.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Deploy backend changes (proxy endpoint + model updates)
|
||||||
|
2. Update frontend links to use proxy URL
|
||||||
|
3. Test with code-server instance
|
||||||
|
4. Monitor API performance
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should we add rate limiting to the proxy endpoint?
|
||||||
|
- Do we need to rewrite response headers (Location, Set-Cookie)?
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Tool instances (code-server, jupyter-notebook) run inside Docker containers with internal network addresses. Currently the "Open" button links to `http://localhost:{port}`, which only works from inside the API container and fails when opened from the user's browser. We need a way to expose these instances to users over HTTPS.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a proxy endpoint to the backend API: `/instances/{id}/proxy/{path:path}`
|
||||||
|
- Proxy requests from the API to the running container (via docker network or internal IP)
|
||||||
|
- Update frontend "Open" button to use the proxy URL instead of `localhost`
|
||||||
|
- Add WebSocket proxy support for real-time features (terminal already uses WebSocket)
|
||||||
|
- Ensure only the instance owner can access the proxied content
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `instance-proxy`: HTTP proxying for running tool instances through the API
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None (this is purely an infrastructure/transport feature, not a change to existing capability requirements)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: New proxy endpoint, container network discovery, request forwarding
|
||||||
|
- Frontend: Update instance "Open" link to use proxy URL
|
||||||
|
- Docker: Containers must be reachable from API container (already true via docker network)
|
||||||
|
- Security: Owner-only access enforced at proxy level
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Proxy endpoint exists for running instances
|
||||||
|
The API SHALL expose an endpoint that forwards HTTP requests to a running tool instance.
|
||||||
|
|
||||||
|
#### Scenario: Access running instance
|
||||||
|
- **WHEN** an authenticated user sends a GET request to `/instances/{id}/proxy/`
|
||||||
|
- **THEN** the request is forwarded to the instance's container
|
||||||
|
- **AND** the response is returned to the user
|
||||||
|
|
||||||
|
#### Scenario: Access instance subpath
|
||||||
|
- **WHEN** an authenticated user sends a request to `/instances/{id}/proxy/api/status`
|
||||||
|
- **THEN** the request is forwarded to `{container_url}/api/status`
|
||||||
|
- **AND** the response is returned to the user
|
||||||
|
|
||||||
|
### Requirement: Only instance owner can access proxy
|
||||||
|
The proxy endpoint SHALL verify that the authenticated user owns the instance before forwarding.
|
||||||
|
|
||||||
|
#### Scenario: Owner accesses instance
|
||||||
|
- **WHEN** the instance owner requests `/instances/{id}/proxy/`
|
||||||
|
- **THEN** the request is forwarded to the instance
|
||||||
|
|
||||||
|
#### Scenario: Non-owner attempts access
|
||||||
|
- **WHEN** a user who does not own the instance requests `/instances/{id}/proxy/`
|
||||||
|
- **THEN** the API returns 403 Forbidden
|
||||||
|
|
||||||
|
### Requirement: Proxy handles WebSocket upgrades
|
||||||
|
The proxy endpoint SHALL support WebSocket upgrade requests for real-time features.
|
||||||
|
|
||||||
|
#### Scenario: WebSocket connection to instance
|
||||||
|
- **WHEN** a user sends a request with `Upgrade: websocket` header
|
||||||
|
- **THEN** the API establishes a bidirectional WebSocket connection to the instance
|
||||||
|
- **AND** messages are relayed between user and instance
|
||||||
|
|
||||||
|
### Requirement: Frontend uses proxy URL for instance access
|
||||||
|
The frontend SHALL link to the proxy endpoint instead of the internal container URL.
|
||||||
|
|
||||||
|
#### Scenario: User clicks Open button
|
||||||
|
- **WHEN** a user clicks "Open" on a running instance
|
||||||
|
- **THEN** a new tab opens to `/instances/{id}/proxy/`
|
||||||
|
- **AND** the proxied instance content is displayed
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## 1. Backend - Proxy Endpoint
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `container_name` field to ToolInstance model and update start_instance to store it
|
||||||
|
- [ ] 1.2 Create proxy endpoint `/instances/{id}/proxy/{path:path}` in tool_instances.py
|
||||||
|
- [ ] 1.3 Implement HTTP forwarding using httpx with streaming support
|
||||||
|
- [ ] 1.4 Add ownership check before proxying
|
||||||
|
- [ ] 1.5 Add WebSocket upgrade support for the proxy endpoint
|
||||||
|
- [ ] 1.6 Handle response header forwarding (Content-Type, cookies, etc.)
|
||||||
|
|
||||||
|
## 2. Backend - Instance URL Update
|
||||||
|
|
||||||
|
- [ ] 2.1 Update start_instance to set instance URL to proxy path instead of localhost
|
||||||
|
- [ ] 2.2 Ensure container_name is captured during start
|
||||||
|
|
||||||
|
## 3. Frontend - Update Instance Links
|
||||||
|
|
||||||
|
- [ ] 3.1 Update InstanceList "Open" button to use proxy URL
|
||||||
|
- [ ] 3.2 Update SessionsPage "Open" button to use proxy URL
|
||||||
|
- [ ] 3.3 Ensure URLs open in new tab
|
||||||
|
|
||||||
|
## 4. Testing & Quality
|
||||||
|
|
||||||
|
- [ ] 4.1 Test proxy with code-server instance
|
||||||
|
- [ ] 4.2 Verify WebSocket features work (terminal inside code-server)
|
||||||
|
- [ ] 4.3 Run quality gates (ruff, mypy, typecheck, lint, build)
|
||||||
|
- [ ] 4.4 Deploy and test end-to-end
|
||||||
Reference in New Issue
Block a user