diff --git a/.gitignore b/.gitignore index 052a2b6d1..33abf3d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -251,3 +251,4 @@ uv.lock # package shadows the maturin overlay on sys.path. /headroom/_core.*.so /headroom/_core.so +plugin/ diff --git a/Dockerfile.vscode-plugin b/Dockerfile.vscode-plugin new file mode 100644 index 000000000..e4b1f0de1 --- /dev/null +++ b/Dockerfile.vscode-plugin @@ -0,0 +1,24 @@ +FROM node:24-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ARG VSCODE_FORK_REPO=https://github.com/damnthonyy/vscode.git +ARG VSCODE_FORK_BRANCH=main + +WORKDIR /build +RUN git clone --depth=1 --branch=${VSCODE_FORK_BRANCH} ${VSCODE_FORK_REPO} vscode + +WORKDIR /build/vscode/extensions/copilot + +# Install deps without running postinstall (needs source files already present from the clone) +RUN npm install --ignore-scripts + +# Run postinstall now that sources are present, build, then package +RUN npm run postinstall && \ + npx tsx .esbuild.mts --sourcemaps && \ + mkdir -p /plugin && \ + npx vsce package --out /plugin/copilot-proxy.vsix --allow-missing-repository + +CMD ["sh", "-c", "cp /plugin/copilot-proxy.vsix /output/ && echo 'Done:' && ls -lh /output/*.vsix"] diff --git a/docker-compose.copilot.yml b/docker-compose.copilot.yml new file mode 100644 index 000000000..3d6c21047 --- /dev/null +++ b/docker-compose.copilot.yml @@ -0,0 +1,17 @@ +# docker-compose.copilot.yml — Copilot overlay for headroom +# +# Routes GitHub Copilot Chat requests through headroom for token compression. +# Requires the patched VS Code extension: https://github.com/damnthonyy/vscode +# +# Usage: +# export GITHUB_TOKEN=$(gh auth token) +# docker compose -f docker-compose.yml -f docker-compose.copilot.yml up headroom-proxy +# +# Then in VS Code settings (Ctrl/Cmd+Shift+P → Open User Settings JSON): +# "github.copilot.chat.proxy.url": "http://localhost:8787/v1" + +services: + headroom-proxy: + environment: + - OPENAI_TARGET_API_URL=https://api.githubcopilot.com + - GITHUB_TOKEN=${GITHUB_TOKEN} diff --git a/docker-compose.yml b/docker-compose.yml index a3a30316e..d99b8be3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: headroom-proxy: - build: . + build: + context: . + args: + HEADROOM_EXTRAS: "proxy,code,ml" command: ["--host", "0.0.0.0"] environment: - HEADROOM_HOST=0.0.0.0 @@ -45,6 +48,22 @@ services: - NEO4J_apoc_import_file_enabled=true - NEO4J_apoc_import_file_use__neo4j__config=true + # Build the patched Copilot Chat VSIX that routes LLM requests through headroom. + # Usage: + # docker compose --profile plugin run --rm vscode-plugin-builder + # code --install-extension ./plugin/copilot-proxy.vsix --force + vscode-plugin-builder: + build: + context: . + dockerfile: Dockerfile.vscode-plugin + args: + VSCODE_FORK_REPO: ${VSCODE_FORK_REPO:-https://github.com/damnthonyy/vscode.git} + VSCODE_FORK_BRANCH: ${VSCODE_FORK_BRANCH:-main} + volumes: + - ./plugin:/output + profiles: + - plugin + volumes: qdrant_data: neo4j_data: diff --git a/headroom/providers/proxy_routes.py b/headroom/providers/proxy_routes.py index d098a97a4..9a029783e 100644 --- a/headroom/providers/proxy_routes.py +++ b/headroom/providers/proxy_routes.py @@ -15,6 +15,20 @@ logger = logging.getLogger("headroom.proxy.routes") +# Allowlist of GitHub Copilot LLM hostnames that the patched VS Code extension +# may forward via X-Original-Host. Only these hosts are trusted; any other value +# is rejected to prevent SSRF attacks (localhost, link-local metadata addresses, +# internal service names, or arbitrary external hosts). +_COPILOT_ALLOWED_HOSTS: frozenset[str] = frozenset( + { + "api.githubcopilot.com", + "api.individual.githubcopilot.com", + "api.business.githubcopilot.com", + "api.enterprise.githubcopilot.com", + "api-model-lab.githubcopilot.com", + } +) + def _api_target(proxy: Any, provider_name: str) -> str: legacy_attrs = { @@ -848,6 +862,14 @@ async def gemini_delete_cached_content(request: Request, cache_id: str): @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) async def passthrough(request: Request, path: str): custom_base = request.headers.get("x-headroom-base-url") + if not custom_base: + original_host = request.headers.get("x-original-host", "") + if original_host in _COPILOT_ALLOWED_HOSTS: + custom_base = f"https://{original_host}" + elif original_host: + logger.warning( + "Rejected X-Original-Host %r: not in Copilot allowlist", original_host + ) if custom_base: return await proxy.handle_passthrough(request, custom_base.rstrip("/")) diff --git a/tests/test_provider_proxy_routes.py b/tests/test_provider_proxy_routes.py index a73d73eee..a3c847188 100644 --- a/tests/test_provider_proxy_routes.py +++ b/tests/test_provider_proxy_routes.py @@ -175,6 +175,61 @@ async def fake_gemini_count( ).json()["base_url"] == "https://custom.example/base" ) + # X-Original-Host support: patched VS Code Copilot extension sends this header + # instead of x-headroom-base-url to avoid modifying the path. + assert ( + client.post( + "/chat/completions", + headers={"x-original-host": "api.githubcopilot.com"}, + ).json()["base_url"] + == "https://api.githubcopilot.com" + ) + # x-headroom-base-url still wins over x-original-host when both are present + assert ( + client.get( + "/chat/completions", + headers={ + "x-headroom-base-url": "https://explicit.example", + "x-original-host": "api.githubcopilot.com", + }, + ).json()["base_url"] + == "https://explicit.example" + ) + # All other Copilot hostnames in the allowlist must also be accepted + for allowed_host in [ + "api.individual.githubcopilot.com", + "api.business.githubcopilot.com", + "api.enterprise.githubcopilot.com", + "api-model-lab.githubcopilot.com", + ]: + assert ( + client.post( + "/chat/completions", + headers={"x-original-host": allowed_host}, + ).json()["base_url"] + == f"https://{allowed_host}" + ), f"Expected {allowed_host} to be accepted" + # SSRF guard: hosts outside the Copilot allowlist must NOT be forwarded. + # The passthrough falls back to the default OpenAI target for these requests. + for rejected_host in [ + "localhost", + "localhost:8080", + "127.0.0.1", + "0.0.0.0", + "169.254.169.254", # AWS/GCP link-local metadata + "internal-service", + "internal-service.corp", + "evil.example.com", + "api.githubcopilot.com.evil.com", # subdomain confusion + "", + ]: + result = client.post( + "/chat/completions", + headers={"x-original-host": rejected_host}, + ).json() + assert result.get("base_url") != f"https://{rejected_host}", ( + f"SSRF: X-Original-Host {rejected_host!r} should have been rejected" + ) assert client.get("/another/path", headers={"x-goog-api-key": "test"}).json()[ "base_url" ] == ("https://api.gemini.test")