mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
Install gh in the template VM, inject a wrapper that routes gh API traffic through the existing git proxy, and extend the proxy to forward API requests to api.github.com with bearer auth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
340 lines
12 KiB
Python
340 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Host-side Git HTTP proxy for Claude Code VMs.
|
|
|
|
Accepts HTTP Git requests from the VM, injects the GitHub token for
|
|
configured repos, and forwards to github.com over HTTPS.
|
|
|
|
Requests targeting other repos are forwarded without credentials.
|
|
|
|
The VM never sees the GitHub token.
|
|
|
|
Usage (multi-repo, preferred):
|
|
GITHUB_GIT_PROXY_REPOS='{"wirenboard/agent-vm":"ghu_xxx","wirenboard/wb-utils":"ghu_yyy"}' \
|
|
python3 github-git-proxy.py
|
|
|
|
Usage (single-repo, backward compat):
|
|
GITHUB_MCP_TOKEN=ghu_... GITHUB_MCP_OWNER=wirenboard GITHUB_MCP_REPO=agent-vm \
|
|
python3 github-git-proxy.py
|
|
|
|
Optional env vars:
|
|
GITHUB_GIT_PROXY_DEBUG Set to "1" for verbose logging
|
|
GITHUB_GIT_PROXY_LOG_DIR Directory for log file (default: current dir)
|
|
"""
|
|
|
|
import base64
|
|
import http.client
|
|
import http.server
|
|
import json
|
|
import os
|
|
import signal
|
|
import ssl
|
|
import sys
|
|
import socketserver
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
DEBUG = os.environ.get("GITHUB_GIT_PROXY_DEBUG", "0") == "1"
|
|
LOG_DIR = os.environ.get("GITHUB_GIT_PROXY_LOG_DIR", ".")
|
|
|
|
# Retry settings for transient upstream errors (5xx)
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAYS = [1, 2, 4]
|
|
|
|
# Build per-repo auth: { "/owner/repo.git/": "Basic ...", "/owner/repo/": "Basic ...", ... }
|
|
_REPO_AUTH = {}
|
|
# Bearer token for API requests (populated from the first repo's token)
|
|
_DEFAULT_BEARER = None
|
|
|
|
|
|
def _make_basic_auth(token):
|
|
return "Basic " + base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
|
|
|
|
def _add_repo_auth(owner, repo, token):
|
|
global _DEFAULT_BEARER
|
|
auth = _make_basic_auth(token)
|
|
_REPO_AUTH[f"/{owner}/{repo}.git/"] = auth
|
|
_REPO_AUTH[f"/{owner}/{repo}/"] = auth
|
|
if _DEFAULT_BEARER is None:
|
|
_DEFAULT_BEARER = token
|
|
|
|
|
|
# Preferred: multi-repo JSON config
|
|
_repos_json = os.environ.get("GITHUB_GIT_PROXY_REPOS", "")
|
|
if _repos_json:
|
|
for slug, token in json.loads(_repos_json).items():
|
|
owner, repo = slug.split("/", 1)
|
|
_add_repo_auth(owner, repo, token)
|
|
else:
|
|
# Backward compat: single-repo env vars
|
|
_TOKEN = os.environ.get("GITHUB_TOKEN", os.environ.get("GITHUB_MCP_TOKEN", ""))
|
|
_OWNER = os.environ.get("GITHUB_MCP_OWNER", "")
|
|
_REPO = os.environ.get("GITHUB_MCP_REPO", "")
|
|
if _TOKEN and _OWNER and _REPO:
|
|
_add_repo_auth(_OWNER, _REPO, _TOKEN)
|
|
|
|
_log_file = None
|
|
|
|
|
|
def _open_log():
|
|
global _log_file
|
|
path = os.path.join(LOG_DIR, "github-git-proxy.log")
|
|
_log_file = open(path, "a")
|
|
|
|
|
|
def log(msg):
|
|
if _log_file:
|
|
_log_file.write(f"[github-git-proxy {time.strftime('%H:%M:%S')}] {msg}\n")
|
|
_log_file.flush()
|
|
|
|
|
|
def debug(msg):
|
|
if DEBUG:
|
|
log(msg)
|
|
|
|
|
|
def _looks_like_html(body_bytes):
|
|
"""Check if a response body is HTML."""
|
|
if body_bytes:
|
|
prefix = body_bytes[:256].lstrip()
|
|
if prefix.startswith((b"<", b"<!DOCTYPE", b"<!doctype")):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _save_error_response(status, body_bytes, context=""):
|
|
"""Save an error response body to a file for debugging. Returns the path."""
|
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
filename = f"github-git-error-{ts}-{status}.html"
|
|
path = os.path.join(LOG_DIR, filename)
|
|
try:
|
|
with open(path, "wb") as f:
|
|
if context:
|
|
f.write(f"<!-- {context} -->\n".encode())
|
|
f.write(body_bytes)
|
|
log(f" saved error response to {path}")
|
|
except OSError as e:
|
|
log(f" failed to save error response: {e}")
|
|
path = None
|
|
return path
|
|
|
|
|
|
class GitProxyHandler(http.server.BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, format, *args):
|
|
debug(format % args)
|
|
|
|
def _match_repo_auth(self):
|
|
"""Return the Basic auth header if the request path matches a configured repo, else None."""
|
|
if not _REPO_AUTH:
|
|
return None
|
|
path = self.path.split("?")[0] + "/"
|
|
for prefix, auth in _REPO_AUTH.items():
|
|
if path.startswith(prefix):
|
|
return auth
|
|
return None
|
|
|
|
def _is_api_request(self):
|
|
"""Detect API requests from gh CLI via HTTP_PROXY.
|
|
|
|
When HTTP_PROXY is set, Go sends the full URL in the request line:
|
|
GET http://api.github.localhost/repos/... HTTP/1.1
|
|
The Host header will contain 'api.github.localhost' or similar.
|
|
Also support /api/ prefix for direct proxy access.
|
|
"""
|
|
host = self.headers.get("Host", "")
|
|
return host.startswith("api.") or self.path.startswith("/api/")
|
|
|
|
def _proxy_api(self):
|
|
"""Forward an API request to api.github.com with bearer auth."""
|
|
if not _DEFAULT_BEARER:
|
|
self.send_error(502, "No GitHub token configured for API requests")
|
|
return
|
|
|
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(content_length) if content_length else None
|
|
|
|
# Extract the path from a proxy-style full URL or a direct path.
|
|
# Full URL: http://api.github.localhost/repos/... -> /repos/...
|
|
# Direct: /api/v3/repos/... -> /repos/...
|
|
path = self.path
|
|
if path.startswith("http://") or path.startswith("https://"):
|
|
# Proxy-style: extract path portion
|
|
path = urlparse(path).path
|
|
# Strip /api/v3 prefix (GHE-style URLs that gh sometimes uses)
|
|
if path.startswith("/api/v3/"):
|
|
path = path[len("/api/v3"):]
|
|
elif path.startswith("/api/"):
|
|
path = path[len("/api"):]
|
|
|
|
debug(f">>> API {self.command} {path} ({content_length} bytes)")
|
|
|
|
# Build upstream headers
|
|
headers = {}
|
|
for key, value in self.headers.items():
|
|
if key.lower() in ("host", "authorization", "accept-encoding"):
|
|
continue
|
|
headers[key] = value
|
|
headers["Host"] = "api.github.com"
|
|
headers["Authorization"] = f"token {_DEFAULT_BEARER}"
|
|
if body:
|
|
headers["Content-Length"] = str(len(body))
|
|
|
|
ctx = ssl.create_default_context()
|
|
last_error = None
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
if attempt > 0:
|
|
delay = RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)]
|
|
log(f" API retry {attempt}/{MAX_RETRIES} after {delay}s")
|
|
time.sleep(delay)
|
|
|
|
conn = None
|
|
try:
|
|
t0 = time.monotonic()
|
|
conn = http.client.HTTPSConnection("api.github.com", 443, context=ctx, timeout=300)
|
|
conn.request(self.command, path, body=body, headers=headers)
|
|
resp = conn.getresponse()
|
|
latency_ms = (time.monotonic() - t0) * 1000
|
|
except Exception as e:
|
|
if conn:
|
|
conn.close()
|
|
log(f" API UPSTREAM ERROR (attempt {attempt + 1}): {e}")
|
|
last_error = str(e)
|
|
continue
|
|
|
|
resp_body = resp.read()
|
|
conn.close()
|
|
debug(f"<<< API {resp.status} ({latency_ms:.0f}ms, {len(resp_body)} bytes)")
|
|
|
|
if resp.status >= 500:
|
|
last_error = f"GitHub API returned HTTP {resp.status}"
|
|
log(f" API got {resp.status} ({len(resp_body)} bytes), will retry")
|
|
continue
|
|
|
|
# Forward response
|
|
self.send_response(resp.status)
|
|
for key, value in resp.getheaders():
|
|
if key.lower() in ("transfer-encoding", "connection", "keep-alive"):
|
|
continue
|
|
self.send_header(key, value)
|
|
self.send_header("Content-Length", str(len(resp_body)))
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
return
|
|
|
|
log(f" API all {MAX_RETRIES + 1} attempts failed: {last_error}")
|
|
self.send_error(502, f"GitHub API unavailable after {MAX_RETRIES + 1} attempts: {last_error}")
|
|
|
|
def proxy(self):
|
|
# Route API requests before git path matching
|
|
if self._is_api_request():
|
|
return self._proxy_api()
|
|
|
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(content_length) if content_length else None
|
|
|
|
matched_auth = self._match_repo_auth()
|
|
debug(f">>> {self.command} {self.path} ({content_length} bytes, auth={'yes' if matched_auth else 'no'})")
|
|
|
|
# Build upstream headers
|
|
headers = {}
|
|
for key, value in self.headers.items():
|
|
if key.lower() in ("host", "authorization"):
|
|
continue
|
|
headers[key] = value
|
|
headers["Host"] = "github.com"
|
|
if matched_auth:
|
|
headers["Authorization"] = matched_auth
|
|
if body:
|
|
headers["Content-Length"] = str(len(body))
|
|
|
|
ctx = ssl.create_default_context()
|
|
last_error = None
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
if attempt > 0:
|
|
delay = RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)]
|
|
log(f" retry {attempt}/{MAX_RETRIES} after {delay}s")
|
|
time.sleep(delay)
|
|
|
|
conn = None
|
|
try:
|
|
t0 = time.monotonic()
|
|
conn = http.client.HTTPSConnection("github.com", 443, context=ctx, timeout=300)
|
|
conn.request(self.command, self.path, body=body, headers=headers)
|
|
resp = conn.getresponse()
|
|
latency_ms = (time.monotonic() - t0) * 1000
|
|
except Exception as e:
|
|
if conn:
|
|
conn.close()
|
|
log(f" UPSTREAM ERROR (attempt {attempt + 1}): {e}")
|
|
last_error = str(e)
|
|
continue
|
|
|
|
resp_body = resp.read()
|
|
conn.close()
|
|
debug(f"<<< {resp.status} ({latency_ms:.0f}ms, {len(resp_body)} bytes)")
|
|
|
|
# On 5xx with HTML body, save the error and retry
|
|
if resp.status >= 500:
|
|
if _looks_like_html(resp_body):
|
|
ctx_msg = f"HTTP {resp.status} from github.com{self.path} attempt {attempt + 1}"
|
|
_save_error_response(resp.status, resp_body, ctx_msg)
|
|
last_error = f"GitHub returned HTTP {resp.status} (HTML error page)"
|
|
log(f" got HTML error page ({len(resp_body)} bytes), will retry")
|
|
else:
|
|
last_error = f"GitHub returned HTTP {resp.status}"
|
|
log(f" got {resp.status} ({len(resp_body)} bytes), will retry")
|
|
continue
|
|
|
|
# Success or non-5xx — forward response
|
|
self.send_response(resp.status)
|
|
for key, value in resp.getheaders():
|
|
if key.lower() in ("transfer-encoding", "connection", "keep-alive"):
|
|
continue
|
|
self.send_header(key, value)
|
|
self.send_header("Content-Length", str(len(resp_body)))
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
return
|
|
|
|
# All retries exhausted
|
|
log(f" all {MAX_RETRIES + 1} attempts failed: {last_error}")
|
|
self.send_error(502, f"GitHub unavailable after {MAX_RETRIES + 1} attempts: {last_error}")
|
|
|
|
do_GET = proxy
|
|
do_POST = proxy
|
|
do_PATCH = proxy
|
|
do_PUT = proxy
|
|
do_DELETE = proxy
|
|
|
|
|
|
class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
|
|
def main():
|
|
if not _REPO_AUTH:
|
|
print("Error: set GITHUB_GIT_PROXY_REPOS or GITHUB_TOKEN+GITHUB_MCP_OWNER+GITHUB_MCP_REPO", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
_open_log()
|
|
|
|
server = Server(("127.0.0.1", 0), GitProxyHandler)
|
|
port = server.server_address[1]
|
|
print(port, flush=True)
|
|
|
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
|
|
# Log configured repos (without tokens)
|
|
repo_slugs = [p.strip("/").removesuffix(".git") for p in _REPO_AUTH if p.endswith(".git/")]
|
|
log(f"Listening on port {port}, forwarding to https://github.com")
|
|
log(f"Token injected for: {', '.join(repo_slugs)}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|