open-swe/agent/utils/github_proxy.py
Adam Moussa e9da186b5f
fix(open-swe): port core GitHub-App scope fallback (#1701), workflows:write kept out of standing scope (#181)
* fix: fall back to core GitHub App scope when optional grants missing (#1701)

* fix: fall back to core GitHub App scope when optional grants missing

Proxy-token minting requested workflows:write and actions:read in the
permission set used for every sandbox. GitHub 422s a token request that
asks for a permission the installation hasn't granted, so any install
without workflows:write failed to mint a token and every run died in
before-agent setup with "GitHub App installation token is unavailable".

_resolve_proxy_token now walks a permission ladder (full -> +workflows ->
core) and returns the first scope that mints, recording the granted scope
so hourly proxy refreshes stay consistent. A missing optional grant now
degrades to the install-time core scope instead of failing the run;
workflow-file HITL pushes still require workflows:write and fail at push
time when it is absent.

* refactor: flatten proxy-token ladder loop with continue

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit f53caff1aa24a7b29d851b267aa3bdfe62c1e935)

Sea Haven fork deviation: upstream #1701 folds workflows:write into the
standing BASE/RUNTIME scope. This fork deliberately keeps workflows:write
OUT of the standing permission ladder (RUNTIME = core + actions:read;
LADDER = (RUNTIME, CORE)) so the sandbox proxy token cannot push
.github/workflows/* during normal operation. workflows:write is minted only
transiently by WorkflowPushGuardMiddleware for an approved HITL push and
dropped on restore, preserving token scope as a backstop for the workflow-
push approval control. Security-reviewed (agentic fan-out + GPT-4.1 cross
review); the standing-scope-carries-workflows:write bypass was blocked.

* fix(open-swe): harden proxy-token restore and mint error handling

Two low-severity follow-ups from the security review of the #1701 port.

Restore the recorded baseline scope after a workflow-push elevation instead
of a hardcoded RUNTIME. An install granted workflows:write but not actions:read
resolves its standing token to core; hardcoding RUNTIME on restore requested the
ungranted actions:read, 422'd, and fired a false "SECURITY: failed to downscope"
error on every approved workflow push before the core fallback recovered. The
guard now captures the run's recorded scope before elevating (via the new
get_recorded_proxy_permissions) and restores exactly that, falling back to the
guaranteed core scope only when the baseline restore fails.

Classify installation-token mint failures. get_github_app_installation_token_
with_expiry now treats HTTP 422 (a permission the installation hasn't granted)
as the ladder's expected descend signal and keeps it at debug, while a non-422
failure (network/5xx/timeout) is surfaced at WARNING even when errors are
otherwise suppressed — so a transient blip no longer silently downscopes a whole
run under a debug-only trace. The reduced-scope warning no longer asserts a
missing grant as the sole cause.

* chore(triage): mark upstream #1701 landed on this branch

Ported via PR #181 as Option A (workflows:write kept out of the standing
proxy-token scope). Regenerated triage.md from triage.jsonl.

---------

Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-13 14:24:37 -04:00

186 lines
7 KiB
Python

"""Track and refresh the GitHub App token baked into a sandbox's proxy.
The LangSmith sandbox proxy is configured once at run start with a GitHub App
installation token. Those tokens expire after exactly one hour, so any agent
run longer than ~1h would start seeing 401s on every ``gh``/``git`` call in the
sandbox. This module records when each thread's proxy token expires and lets a
before-model middleware re-configure the proxy before it goes stale.
"""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any
from .github_app import (
PermissionKey,
PermissionMap,
get_github_app_installation_token_with_expiry,
normalize_permissions,
)
from .sandbox_state import SANDBOX_BACKENDS, unwrap_sandbox_backend
logger = logging.getLogger(__name__)
# Refresh the proxy token once it is within this window of expiring.
PROXY_TOKEN_REFRESH_WINDOW = timedelta(minutes=5)
# Used only when the token's own expiry is unknown: refresh after this age.
PROXY_TOKEN_FALLBACK_TTL = timedelta(minutes=50)
# thread_id -> (token_expires_at | None, recorded_at, repositories scope | None, permission scope)
_PROXY_TOKEN_EXPIRY: dict[
str, tuple[datetime | None, datetime, tuple[str, ...] | None, PermissionKey]
] = {}
ProxyTokenRecord = tuple[datetime | None, datetime, tuple[str, ...] | None, PermissionKey]
def _parse_expiry(expires_at: Any) -> datetime | None:
"""Best-effort parse of a GitHub ``expires_at`` value to an aware datetime."""
if expires_at is None:
return None
if isinstance(expires_at, datetime):
return expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=UTC)
if isinstance(expires_at, int | float):
try:
return datetime.fromtimestamp(float(expires_at), tz=UTC)
except (OverflowError, OSError, ValueError):
return None
if isinstance(expires_at, str):
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)
return None
def record_proxy_token_expiry(
thread_id: str | None,
expires_at: Any,
*,
repositories: Sequence[str] | None = None,
permissions: PermissionMap | None = None,
) -> None:
"""Record when ``thread_id``'s proxy token expires and the repo scope it was minted with.
``repositories`` and ``permissions`` preserve the original token scope so a
later refresh doesn't broaden it to an installation-wide or more privileged token.
"""
if not thread_id:
return
scope = tuple(repositories) if repositories else None
_PROXY_TOKEN_EXPIRY[thread_id] = (
_parse_expiry(expires_at),
datetime.now(UTC),
scope,
normalize_permissions(permissions),
)
def clear_proxy_token_expiry(thread_id: str | None) -> None:
if thread_id:
_PROXY_TOKEN_EXPIRY.pop(thread_id, None)
def get_recorded_proxy_permissions(thread_id: str | None) -> dict[str, str] | None:
"""The permission scope last minted for ``thread_id``'s proxy token, if any.
Lets a caller that temporarily elevates the proxy scope restore the exact
baseline the run resolved to (e.g. an install granted workflows:write but not
actions:read resolves to core), instead of guessing a fixed scope that may
422 on restore.
"""
if not thread_id:
return None
record = _PROXY_TOKEN_EXPIRY.get(thread_id)
if record is None:
return None
*_, permission_key = _unpack_proxy_token_record(record)
return dict(permission_key) if permission_key else None
def _unpack_proxy_token_record(record: tuple[Any, ...]) -> ProxyTokenRecord:
expires_at, recorded_at, repositories, *rest = record
permissions = rest[0] if rest else ()
permission_key = permissions if isinstance(permissions, tuple) else normalize_permissions(None)
return expires_at, recorded_at, repositories, permission_key
def proxy_token_needs_refresh(thread_id: str | None, *, now: datetime | None = None) -> bool:
"""Whether the recorded proxy token is at/near expiry and should be refreshed."""
if not thread_id:
return False
record = _PROXY_TOKEN_EXPIRY.get(thread_id)
if record is None:
return False
expires_at, recorded_at, _scope, _permissions = _unpack_proxy_token_record(record)
current = (now or datetime.now(UTC)).astimezone(UTC)
if expires_at is not None:
return (expires_at - current) <= PROXY_TOKEN_REFRESH_WINDOW
return (current - recorded_at) >= PROXY_TOKEN_FALLBACK_TTL
async def refresh_proxy_token(
thread_id: str | None,
*,
repositories: Sequence[str] | None = None,
permissions: PermissionMap | None = None,
) -> bool:
"""Re-configure a LangSmith sandbox proxy with a freshly minted token."""
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith" or not thread_id:
return False
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
if sandbox_backend is None:
return False
_expires, _recorded, recorded_repositories, recorded_permissions = _unpack_proxy_token_record(
_PROXY_TOKEN_EXPIRY.get(thread_id, (None, None, None, ()))
)
effective_repositories = tuple(repositories) if repositories else recorded_repositories
permission_key = normalize_permissions(permissions) or recorded_permissions
token_kwargs: dict[str, Any] = {}
if effective_repositories:
token_kwargs["repositories"] = list(effective_repositories)
if permission_key:
token_kwargs["permissions"] = dict(permission_key)
token, expires_at = await get_github_app_installation_token_with_expiry(**token_kwargs)
if not token:
logger.warning("Proxy token refresh for thread %s failed: no installation token", thread_id)
return False
from ..integrations.langsmith import _configure_github_proxy
current_backend = unwrap_sandbox_backend(sandbox_backend)
await asyncio.to_thread(_configure_github_proxy, current_backend.id, token)
record_proxy_token_expiry(
thread_id,
expires_at,
repositories=effective_repositories,
permissions=dict(permission_key) if permission_key else None,
)
logger.info("Refreshed GitHub proxy token for thread %s", thread_id)
return True
async def maybe_refresh_proxy_token(thread_id: str | None, *, now: datetime | None = None) -> bool:
"""Re-configure the sandbox proxy with a fresh token when near expiry.
Returns True when a refresh was performed. Only applies to LangSmith
sandboxes; other providers don't use the proxy.
"""
if not thread_id or not proxy_token_needs_refresh(thread_id, now=now):
return False
refreshed = await refresh_proxy_token(thread_id)
if refreshed:
logger.info("Refreshed GitHub proxy token for thread %s before expiry", thread_id)
return refreshed