open-swe/agent/utils/github_app.py
Johannes du Plessis 209132d355
refactor: durable interrupt dispatch + completion webhook (#1621)
* wip(rebuild): core reliability spine

- remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring)
- dispatch core: agent/dispatch.py with multitask_strategy=interrupt +
  durability=sync + completion webhook; reroute all webhook + plan triggers;
  drop the racy in-process lock + is_thread_active busy-check
- completion webhook: agent/completion.py + /webhooks/run-complete loopback
  route for failure/timeout replies (idempotent)

Co-authored-by: open-swe[bot]

* feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning

Parallel batch on top of the reliability spine:
- async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the
  http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden
  the IP check to 'not is_global' (+ IPv4-mapped unwrap)
- reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list
  -> cancel_many), wired into the scheduler graph via task='reconcile'
- shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare
  httpx.AsyncClient() across utils/dashboard/webapp/middleware
- run budget: MODEL_CALL_RECURSION_LIMIT 5000->250
- fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8)
- drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls)
- confirm tool-result eviction + summarization auto-wired via backend
- slim system prompt ~8% (full harness-profile rewrite deferred)

Co-authored-by: open-swe[bot]

* feat(rebuild): harness-profile prompt + split webhooks out of webapp

- prompt.py: own the system prompt via a registered harness profile
  (OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that
  share it stay safe), registered across all 4 providers; per-thread values
  stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k
  tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped
  ALL-CAPS markers.
- webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into
  agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the
  routes + tests; moved handlers reach shared helpers via the webapp namespace
  to preserve the test suite's monkeypatch targets.

Full suite: 1168 passing, lint clean.

Co-authored-by: open-swe[bot]

* Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks

Reverts the 250 cap from the run-budget change — long-running tasks legitimately
need many model calls. The notify_step_limit_reached safety net still fires if a
run does hit the cap, so runs end with a signal either way.

Co-authored-by: open-swe[bot]

* fix: address PR review (auth, SSRF, interrupted status, redirect headers)

- completion.py: drop `interrupted` from failure statuses — with
  multitask_strategy=interrupt a follow-up ends the prior run as interrupted,
  which is healthy, not a failure to report. [open-swe]
- /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when
  RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest.
  [corridor-security]
- SSRF: extract the URL validator to agent/utils/url_safety.py and apply it
  before server-side image fetches in multimodal.fetch_image_block.
  [corridor-security]
- http_request: preserve caller headers/extensions across redirect hops instead
  of dropping them on the first hop. [open-swe]

Co-authored-by: open-swe[bot]

* chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo)

Co-authored-by: open-swe[bot]

* fix: fail closed on run-complete webhook auth when secret unset

Corridor follow-up: verify_run_complete_token returns False (not True) when
RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never
unauthenticated. Logs a startup warning when the secret is absent, and dispatch
skips registering the webhook when there's no secret (no rejected callbacks).

Co-authored-by: open-swe[bot]

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-26 13:48:38 -07:00

172 lines
6.1 KiB
Python

"""GitHub App installation token generation."""
from __future__ import annotations
import logging
import os
import time
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import jwt
from .http import DEFAULT_HTTP_TIMEOUT
logger = logging.getLogger(__name__)
GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID", "")
GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY", "")
GITHUB_APP_INSTALLATION_ID = os.environ.get("GITHUB_APP_INSTALLATION_ID", "")
# Installation tokens are valid for 1 hour. Reuse a minted token until it is
# within this window of expiring so chat/review requests don't pay a fresh
# JWT-sign + GitHub round-trip every message. The margin stays above the proxy's
# 5-minute refresh window (``github_proxy.PROXY_TOKEN_REFRESH_WINDOW``) so a
# near-expiry proxy refresh still mints a genuinely fresh token.
_TOKEN_CACHE_MARGIN = timedelta(minutes=10)
RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
"contents": "write",
"pull_requests": "write",
"issues": "write",
"checks": "write",
}
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
**RUNTIME_PROXY_TOKEN_PERMISSIONS,
"workflows": "write",
}
PermissionMap = Mapping[str, str]
PermissionKey = tuple[tuple[str, str], ...]
ScopeKey = tuple[tuple[int, ...], tuple[str, ...], PermissionKey]
# scope key -> (token, expires_at, good_until). In-process only; never persisted.
_TOKEN_CACHE: dict[ScopeKey, tuple[str, str | None, datetime]] = {}
def normalize_permissions(permissions: PermissionMap | None) -> PermissionKey:
"""Return a stable, hashable permission scope key."""
if not permissions:
return ()
return tuple(sorted((str(k), str(v)) for k, v in permissions.items() if str(k) and str(v)))
def _scope_key(
repository_ids: Sequence[int] | None,
repositories: Sequence[str] | None,
permissions: PermissionMap | None = None,
) -> ScopeKey:
"""Cache key segregating repo and permission-scoped tokens."""
ids = tuple(sorted(int(i) for i in repository_ids)) if repository_ids else ()
names = tuple(sorted(str(r) for r in repositories)) if repositories else ()
return ids, names, normalize_permissions(permissions)
def _parse_expiry(expires_at: Any) -> datetime | None:
"""Best-effort parse of a GitHub ``expires_at`` ISO timestamp to a UTC datetime."""
if not isinstance(expires_at, str):
return None
raw = expires_at.strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
def _cached_token(key: ScopeKey, *, now: datetime) -> tuple[str, str | None] | None:
cached = _TOKEN_CACHE.get(key)
if cached is None:
return None
token, expires_at, good_until = cached
if now < good_until:
return token, expires_at
_TOKEN_CACHE.pop(key, None)
return None
def clear_app_token_cache() -> None:
"""Drop all cached installation tokens (test/maintenance hook)."""
_TOKEN_CACHE.clear()
def _generate_app_jwt() -> str:
"""Generate a short-lived JWT signed with the GitHub App private key."""
now = int(time.time())
payload = {
"iat": now - 60, # issued 60s ago to account for clock skew
"exp": now + 540, # expires in 9 minutes (max is 10)
"iss": GITHUB_APP_ID,
}
private_key = GITHUB_APP_PRIVATE_KEY.replace("\\n", "\n")
return jwt.encode(payload, private_key, algorithm="RS256")
async def get_github_app_installation_token(
*,
repository_ids: Sequence[int] | None = None,
repositories: Sequence[str] | None = None,
permissions: PermissionMap | None = None,
) -> str | None:
"""Exchange the GitHub App JWT for an installation access token."""
token, _ = await get_github_app_installation_token_with_expiry(
repository_ids=repository_ids,
repositories=repositories,
permissions=permissions,
)
return token
async def get_github_app_installation_token_with_expiry(
*,
repository_ids: Sequence[int] | None = None,
repositories: Sequence[str] | None = None,
permissions: PermissionMap | None = None,
) -> tuple[str | None, str | None]:
"""Exchange the GitHub App JWT for an installation access token and its expiry."""
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY or not GITHUB_APP_INSTALLATION_ID:
logger.debug("GitHub App env vars not fully configured, skipping app token")
return None, None
key = _scope_key(repository_ids, repositories, permissions)
now = datetime.now(UTC)
cached = _cached_token(key, now=now)
if cached is not None:
return cached
body: dict[str, Any] = {}
if repository_ids:
body["repository_ids"] = list(repository_ids)
elif repositories:
body["repositories"] = list(repositories)
permission_key = normalize_permissions(permissions)
if permission_key:
body["permissions"] = dict(permission_key)
try:
app_jwt = _generate_app_jwt()
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client:
response = await client.post(
f"https://api.github.com/app/installations/{GITHUB_APP_INSTALLATION_ID}/access_tokens",
headers={
"Authorization": f"Bearer {app_jwt}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=body or None,
)
response.raise_for_status()
data = response.json()
token, expires_at = data.get("token"), data.get("expires_at")
parsed = _parse_expiry(expires_at)
if isinstance(token, str) and token and parsed is not None:
_TOKEN_CACHE[key] = (token, expires_at, parsed - _TOKEN_CACHE_MARGIN)
return token, expires_at
except Exception:
logger.exception("Failed to get GitHub App installation token")
return None, None