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
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
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_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
@@ -24,6 +26,7 @@ from src.services.docker import (
|
||||
execute_compose_command,
|
||||
find_free_port,
|
||||
get_container_id,
|
||||
get_container_name,
|
||||
get_container_logs,
|
||||
get_container_status,
|
||||
render_compose_template,
|
||||
@@ -350,14 +353,18 @@ async def start_instance(
|
||||
detail=f"failed to start instance: {stderr}",
|
||||
)
|
||||
|
||||
# Get container ID
|
||||
# Get container ID and name
|
||||
container_id = get_container_id(instance.name)
|
||||
if 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.last_started_at = datetime.now()
|
||||
instance.url = f"http://localhost:{instance.port}"
|
||||
instance.url = f"/instances/{instance.id}/proxy/"
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
@@ -547,6 +554,136 @@ async def get_instance_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
|
||||
|
||||
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.ssh_keys import router as ssh_keys_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 sessions_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_instances_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -38,6 +38,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
container_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
container_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
compose_path: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
|
||||
@@ -113,6 +113,26 @@ def get_container_id(instance_name: str) -> str | 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:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "../api/sessions";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface InstanceListProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
@@ -147,7 +149,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" && instance.url && (
|
||||
<a
|
||||
href={instance.url}
|
||||
href={`${API_BASE_URL}${instance.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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