mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 16:13:15 +00:00
* 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>
172 lines
6.1 KiB
Python
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
|