mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Merge remote-tracking branch 'origin/dev' into feature/port-linear-pr-author
# Conflicts: # docs/upstream-sync/triage.jsonl # docs/upstream-sync/triage.md
This commit is contained in:
commit
b8f08a4d7b
18 changed files with 1212 additions and 67 deletions
|
|
@ -9,6 +9,7 @@ _MIDDLEWARE_MODULES = {
|
|||
"ModelFallbackMiddleware": ".model_fallback",
|
||||
"notify_step_limit_reached": ".notify_step_limit",
|
||||
"PlanModeMiddleware": ".plan_mode",
|
||||
"PullRequestCreationGuardMiddleware": ".pr_creation_guard",
|
||||
"refresh_github_proxy_before_model": ".refresh_github_proxy",
|
||||
"RepairOrphanedToolCallsMiddleware": ".repair_orphaned_tool_calls",
|
||||
"SlackAssistantStatusMiddleware": ".refresh_slack_status",
|
||||
|
|
@ -31,6 +32,7 @@ __all__ = [
|
|||
"ExcludeToolsMiddleware",
|
||||
"ModelFallbackMiddleware",
|
||||
"PlanModeMiddleware",
|
||||
"PullRequestCreationGuardMiddleware",
|
||||
"RepairOrphanedToolCallsMiddleware",
|
||||
"SanitizeFireworksMessagesMiddleware",
|
||||
"SanitizeOpenAIResponsesMiddleware",
|
||||
|
|
@ -59,6 +61,7 @@ if TYPE_CHECKING:
|
|||
from .model_fallback import ModelFallbackMiddleware
|
||||
from .notify_step_limit import notify_step_limit_reached
|
||||
from .plan_mode import PlanModeMiddleware
|
||||
from .pr_creation_guard import PullRequestCreationGuardMiddleware
|
||||
from .refresh_github_proxy import refresh_github_proxy_before_model
|
||||
from .refresh_slack_status import SlackAssistantStatusMiddleware
|
||||
from .repair_orphaned_tool_calls import RepairOrphanedToolCallsMiddleware
|
||||
|
|
|
|||
249
agent/middleware/pr_creation_guard.py
Normal file
249
agent/middleware/pr_creation_guard.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Block shell fallbacks that create pull requests outside open_pull_request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, AgentState
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
_SHELL_SEPARATORS = {";", "&&", "||", "|", "&"}
|
||||
_GITHUB_PULLS_ENDPOINT = re.compile(r"(?:^|/)repos/[^/\s]+/[^/\s]+/pulls/?$")
|
||||
_GITHUB_PULLS_URL = re.compile(r"https://api\.github\.com/repos/[^/\s]+/[^/\s]+/pulls/?")
|
||||
_BLOCK_ERROR = (
|
||||
"New pull requests must be opened with the open_pull_request tool so the PR is "
|
||||
"attributed to the triggering user. If open_pull_request failed, surface that "
|
||||
"failure instead of falling back to gh pr create, gh api /pulls, curl, or another "
|
||||
"direct PR creation path."
|
||||
)
|
||||
|
||||
|
||||
def _tool_name(request: ToolCallRequest) -> str | None:
|
||||
tool_call = getattr(request, "tool_call", None)
|
||||
if isinstance(tool_call, Mapping):
|
||||
name = tool_call.get("name")
|
||||
return name if isinstance(name, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _tool_args(request: ToolCallRequest) -> dict[str, Any]:
|
||||
tool_call = getattr(request, "tool_call", None)
|
||||
args = tool_call.get("args") if isinstance(tool_call, Mapping) else None
|
||||
return dict(args) if isinstance(args, Mapping) else {}
|
||||
|
||||
|
||||
def _tool_call_id(request: ToolCallRequest) -> str | None:
|
||||
tool_call = getattr(request, "tool_call", None)
|
||||
if isinstance(tool_call, Mapping):
|
||||
value = tool_call.get("id")
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _shell_tokens(command: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(command, posix=True)
|
||||
except ValueError:
|
||||
return command.split()
|
||||
|
||||
|
||||
def _is_assignment(token: str) -> bool:
|
||||
name, sep, _value = token.partition("=")
|
||||
return bool(sep and name and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name))
|
||||
|
||||
|
||||
def _gh_subtokens(tokens: list[str], index: int) -> list[str]:
|
||||
subtokens: list[str] = []
|
||||
for token in tokens[index + 1 :]:
|
||||
if token in _SHELL_SEPARATORS:
|
||||
break
|
||||
subtokens.append(token)
|
||||
return subtokens
|
||||
|
||||
|
||||
def _contains_gh_pr_create(tokens: list[str]) -> bool:
|
||||
for index, token in enumerate(tokens):
|
||||
if token != "gh":
|
||||
continue
|
||||
subtokens = _gh_subtokens(tokens, index)
|
||||
for offset, subtoken in enumerate(subtokens[:-1]):
|
||||
if subtoken == "pr" and subtokens[offset + 1] == "create":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_GH_API_VALUE_FLAGS = {
|
||||
"-X",
|
||||
"--method",
|
||||
"-H",
|
||||
"--header",
|
||||
"-F",
|
||||
"--field",
|
||||
"-f",
|
||||
"--raw-field",
|
||||
"--hostname",
|
||||
"--input",
|
||||
"-q",
|
||||
"--jq",
|
||||
"-p",
|
||||
"--preview",
|
||||
"--cache",
|
||||
"-t",
|
||||
"--template",
|
||||
}
|
||||
|
||||
|
||||
def _gh_api_endpoint(subtokens: list[str]) -> str | None:
|
||||
for index, token in enumerate(subtokens):
|
||||
if token != "api":
|
||||
continue
|
||||
skip_next = False
|
||||
for candidate in subtokens[index + 1 :]:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if candidate.startswith("-"):
|
||||
if "=" not in candidate and candidate in _GH_API_VALUE_FLAGS:
|
||||
skip_next = True
|
||||
continue
|
||||
if _is_assignment(candidate):
|
||||
continue
|
||||
return candidate.strip("'\"")
|
||||
return None
|
||||
|
||||
|
||||
def _gh_api_uses_post_or_body(subtokens: list[str]) -> bool:
|
||||
body_flags = {"-f", "--field", "-F", "--raw-field", "--input"}
|
||||
for index, token in enumerate(subtokens):
|
||||
upper = token.upper()
|
||||
if upper in {"-XPOST", "--METHOD=POST"}:
|
||||
return True
|
||||
if token in {"-X", "--method"} and index + 1 < len(subtokens):
|
||||
if subtokens[index + 1].upper() == "POST":
|
||||
return True
|
||||
if token.startswith("--method=") and token.split("=", 1)[1].upper() == "POST":
|
||||
return True
|
||||
if token in body_flags or any(token.startswith(f"{flag}=") for flag in body_flags):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _contains_gh_api_pull_create(tokens: list[str]) -> bool:
|
||||
for index, token in enumerate(tokens):
|
||||
if token != "gh":
|
||||
continue
|
||||
subtokens = _gh_subtokens(tokens, index)
|
||||
if "api" not in subtokens:
|
||||
continue
|
||||
endpoint = _gh_api_endpoint(subtokens)
|
||||
if (
|
||||
endpoint
|
||||
and _GITHUB_PULLS_ENDPOINT.search(endpoint)
|
||||
and _gh_api_uses_post_or_body(subtokens)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _contains_direct_pull_create(tokens: list[str]) -> bool:
|
||||
for index, token in enumerate(tokens):
|
||||
if token != "curl":
|
||||
continue
|
||||
subtokens: list[str] = []
|
||||
for candidate in tokens[index + 1 :]:
|
||||
if candidate in _SHELL_SEPARATORS:
|
||||
break
|
||||
subtokens.append(candidate)
|
||||
if not any(_GITHUB_PULLS_URL.search(candidate) for candidate in subtokens):
|
||||
continue
|
||||
has_post = any(
|
||||
token.upper() in {"-XPOST", "--REQUEST=POST"}
|
||||
or (
|
||||
token in {"-X", "--request"}
|
||||
and idx + 1 < len(subtokens)
|
||||
and subtokens[idx + 1].upper() == "POST"
|
||||
)
|
||||
or (token.startswith("--request=") and token.split("=", 1)[1].upper() == "POST")
|
||||
for idx, token in enumerate(subtokens)
|
||||
)
|
||||
has_body = any(token in {"-d", "--data", "--data-raw", "--json"} for token in subtokens)
|
||||
if has_post or has_body:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_pr_creation_fallback_command(command: str) -> bool:
|
||||
"""Return True when *command* is a known PR-creation shell fallback.
|
||||
|
||||
Detection is literal-token matching — it catches the primary vectors
|
||||
(``gh pr create``, ``gh api repos/.../pulls -X POST``, ``curl`` to
|
||||
``/pulls``) but is intentionally fail-open: shell aliases, ``gh``
|
||||
aliases (``gh prc`` set via ``gh alias set``), flags between ``pr`` and
|
||||
``create`` (``gh pr --repo x create``), and non-curl HTTP clients
|
||||
(``python -c …``, ``wget``, etc.) will not be blocked. This is
|
||||
acceptable because the threat model is an honest agent papering over an
|
||||
``open_pull_request`` failure, not an adversary trying to bypass the
|
||||
guardrail.
|
||||
"""
|
||||
tokens = _shell_tokens(command)
|
||||
return (
|
||||
_contains_gh_pr_create(tokens)
|
||||
or _contains_gh_api_pull_create(tokens)
|
||||
or _contains_direct_pull_create(tokens)
|
||||
)
|
||||
|
||||
|
||||
def _blocked_tool_message(request: ToolCallRequest, command: str) -> ToolMessage:
|
||||
content = {
|
||||
"status": "error",
|
||||
"error_type": "PullRequestCreationFallbackBlocked",
|
||||
"code": "pr_creation_fallback_blocked",
|
||||
"recoverable_by_agent": False,
|
||||
"error": _BLOCK_ERROR,
|
||||
"blocked_command": command,
|
||||
}
|
||||
return ToolMessage(
|
||||
content=json.dumps(content),
|
||||
tool_call_id=_tool_call_id(request),
|
||||
status="error",
|
||||
)
|
||||
|
||||
|
||||
class PullRequestCreationGuardMiddleware(AgentMiddleware):
|
||||
"""Prevent attributed-PR failures from being hidden by shell fallbacks."""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
def _blocked_message_for_request(self, request: ToolCallRequest) -> ToolMessage | None:
|
||||
if _tool_name(request) != "execute":
|
||||
return None
|
||||
command = _tool_args(request).get("command")
|
||||
if not isinstance(command, str) or not is_pr_creation_fallback_command(command):
|
||||
return None
|
||||
return _blocked_tool_message(request, command)
|
||||
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
blocked = self._blocked_message_for_request(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return handler(request)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
blocked = self._blocked_message_for_request(request)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return await handler(request)
|
||||
|
|
@ -31,11 +31,11 @@ from ..dashboard.workflow_approval import (
|
|||
from ..tools.slack_thread_reply import build_workflow_approval_blocks
|
||||
from ..utils.dashboard_links import dashboard_workflow_approval_url
|
||||
from ..utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
from ..utils.github_proxy import refresh_proxy_token
|
||||
from ..utils.github_proxy import get_recorded_proxy_permissions, refresh_proxy_token
|
||||
from ..utils.sandbox_state import SANDBOX_BACKENDS
|
||||
from ..utils.slack import post_slack_thread_reply_with_ts
|
||||
|
||||
|
|
@ -876,6 +876,13 @@ async def _run_with_workflow_token(
|
|||
if sandbox_type != "langsmith":
|
||||
return await run()
|
||||
|
||||
# Capture the run's standing scope *before* elevating so we restore exactly
|
||||
# what the install resolved to (RUNTIME, or CORE when actions:read is absent)
|
||||
# rather than a hardcoded RUNTIME that 422s on installs lacking actions:read.
|
||||
# The standing ladder never carries workflows:write, so this is always a real
|
||||
# downscope. Fall back to RUNTIME if nothing was recorded yet.
|
||||
baseline_scope = get_recorded_proxy_permissions(thread_id) or RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
|
||||
elevated = await refresh_proxy_token(
|
||||
thread_id, permissions=WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
|
|
@ -904,21 +911,22 @@ async def _run_with_workflow_token(
|
|||
try:
|
||||
return await run()
|
||||
finally:
|
||||
restored = await refresh_proxy_token(thread_id, permissions=RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
if not restored:
|
||||
restored = await refresh_proxy_token(thread_id, permissions=baseline_scope)
|
||||
if not restored and baseline_scope != CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS:
|
||||
logger.error(
|
||||
"SECURITY: failed to downscope proxy token for thread %s after an approved "
|
||||
"workflow push; retrying without actions:read.",
|
||||
"SECURITY: failed to restore baseline proxy scope for thread %s after an "
|
||||
"approved workflow push; retrying at the guaranteed core scope.",
|
||||
thread_id,
|
||||
)
|
||||
restored = await refresh_proxy_token(
|
||||
thread_id, permissions=CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
if not restored:
|
||||
logger.error(
|
||||
"SECURITY: proxy token downscope fully failed for thread %s; the sandbox "
|
||||
"may retain workflows:write for the remainder of this run.",
|
||||
thread_id,
|
||||
)
|
||||
if not await refresh_proxy_token(
|
||||
thread_id, permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
):
|
||||
logger.error(
|
||||
"SECURITY: proxy token downscope fully failed for thread %s; the sandbox "
|
||||
"may retain workflows:write for the remainder of this run.",
|
||||
thread_id,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowPushGuardMiddleware(AgentMiddleware):
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ If a Slack- or GitHub-triggered request asks you to review a GitHub pull request
|
|||
|
||||
**For code-change tasks:** Understand the task and explore relevant files first. Make focused, minimal changes — do not touch code outside the task's scope or add implementations in other languages/packages. Verify with linters and only the tests related to your changes. Then commit, push, and (when a PR is warranted) open/update the draft PR — see Committing below.
|
||||
|
||||
**For information-only requests:** Gather what you need and answer in the source channel. Never leave a question unanswered. Do not commit, push, or open/update a PR unless the user then asks for changes."""
|
||||
**For information-only requests:** First identify any relevant git repositories and check them out before answering, so your response is grounded in current repo state. Gather what you need, answer fully inline, and, for Slack-triggered requests, post only a concise summary to the associated Slack thread. Never leave a question unanswered. Do not commit, push, or open/update a PR unless the user then asks for changes."""
|
||||
|
||||
|
||||
CORRIDOR_PROMPT = """---
|
||||
|
|
@ -307,7 +307,7 @@ Steps, in order:
|
|||
|
||||
**IMPORTANT: Never force-push.** Never run `git push --force` or `git push --force-with-lease`, and never amend or rebase commits that are already on the remote branch — reviewers rely on inter-commit diffs. Add follow-up work as new commits. If a normal push is rejected because the remote branch has new commits, run `git pull --rebase origin <branch>` and push again; if that conflicts, report it and stop.
|
||||
|
||||
**IMPORTANT: If `git push`, `open_pull_request`, or `gh pr edit` fails with an infrastructure or permission error, do not retry blindly. Report the failure and end the task.**
|
||||
**IMPORTANT: If `git push`, `open_pull_request`, or `gh pr edit` fails with an infrastructure/permission/access error — including "403", "404"/"Not Found" from `open_pull_request`, "GitHub App not installed/access denied", or "Permission denied" — do not retry via `gh pr create`, `gh api repos/.../pulls`, direct REST `POST /repos/.../pulls`, or any other PR creation fallback. Report the failure to the user and end the task.**
|
||||
|
||||
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from .integrations.notion_mcp import load_notion_tools
|
|||
from .middleware import (
|
||||
ModelFallbackMiddleware,
|
||||
PlanModeMiddleware,
|
||||
PullRequestCreationGuardMiddleware,
|
||||
SandboxCircuitBreakerMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
|
|
@ -114,8 +115,7 @@ from .utils.authorship import (
|
|||
from .utils.dashboard_links import dashboard_plan_url, dashboard_thread_url
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
PROXY_TOKEN_PERMISSION_LADDER,
|
||||
PermissionMap,
|
||||
get_github_app_installation_token_with_expiry,
|
||||
)
|
||||
|
|
@ -215,18 +215,25 @@ async def _resolve_proxy_token(
|
|||
)
|
||||
return token, expires_at, permissions
|
||||
|
||||
token, expires_at = await get_github_app_installation_token_with_expiry(
|
||||
permissions=RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
log_errors=False,
|
||||
)
|
||||
if token:
|
||||
return token, expires_at, RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
|
||||
logger.warning("Retrying GitHub proxy token mint without optional Actions read permission")
|
||||
token, expires_at = await get_github_app_installation_token_with_expiry(
|
||||
permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
return token, expires_at, BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS if token else None
|
||||
# Walk from the richest scope to the guaranteed core so an installation that
|
||||
# hasn't granted workflows:write / actions:read degrades instead of failing.
|
||||
ladder = PROXY_TOKEN_PERMISSION_LADDER
|
||||
last = len(ladder) - 1
|
||||
for index, scope in enumerate(ladder):
|
||||
token, expires_at = await get_github_app_installation_token_with_expiry(
|
||||
permissions=scope,
|
||||
log_errors=index == last,
|
||||
)
|
||||
if not token:
|
||||
continue
|
||||
if index:
|
||||
logger.warning(
|
||||
"GitHub proxy token minted with reduced scope %s; a higher-privilege "
|
||||
"scope was unavailable (missing grant or transient mint failure)",
|
||||
sorted(scope),
|
||||
)
|
||||
return token, expires_at, scope
|
||||
return None, None, None
|
||||
|
||||
|
||||
async def _resolve_snapshot_id_for_repo(repo: dict[str, str] | None) -> str | None:
|
||||
|
|
@ -1025,6 +1032,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
max_delay=10.0,
|
||||
),
|
||||
ToolArtifactMiddleware(),
|
||||
PullRequestCreationGuardMiddleware(),
|
||||
WorkflowPushGuardMiddleware(),
|
||||
refresh_github_proxy_before_model,
|
||||
check_message_queue_before_model,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from langgraph.config import get_config
|
||||
|
|
@ -21,6 +22,9 @@ logger = logging.getLogger(__name__)
|
|||
GITHUB_API = "https://api.github.com"
|
||||
_USER_TOKEN_SOURCES = ("slack", "linear", "dashboard")
|
||||
_REFERENCES_HEADING = "## References"
|
||||
_ACCESS_FAILURE_CODE = "github_app_access_missing_or_repo_not_found"
|
||||
_BRANCH_FAILURE_CODE = "github_pr_branch_not_visible"
|
||||
_PREFLIGHT_FAILURE_CODE = "github_pr_preflight_failed"
|
||||
|
||||
|
||||
async def _resolve_pr_author_token() -> tuple[str | None, str]:
|
||||
|
|
@ -68,6 +72,360 @@ def _auth_headers(token: str) -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
def _github_message(resp: httpx.Response) -> str:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return resp.text.strip() or f"HTTP {resp.status_code}"
|
||||
if isinstance(data, dict):
|
||||
message = data.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
return resp.text.strip() or f"HTTP {resp.status_code}"
|
||||
|
||||
|
||||
def _configurable() -> dict[str, Any]:
|
||||
try:
|
||||
config = get_config()
|
||||
except Exception:
|
||||
return {}
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
return dict(configurable) if isinstance(configurable, dict) else {}
|
||||
|
||||
|
||||
def _head_branch_for_repo(owner: str, head: str) -> str | None:
|
||||
if ":" not in head:
|
||||
return head
|
||||
head_owner, branch = head.split(":", 1)
|
||||
if head_owner == owner and branch:
|
||||
return branch
|
||||
return None
|
||||
|
||||
|
||||
def _failure_payload(
|
||||
*,
|
||||
code: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
token_kind: str,
|
||||
http_status: int | None,
|
||||
reason: str,
|
||||
likely_cause: str,
|
||||
suggested_action: str,
|
||||
branch_pushed: bool | None,
|
||||
failed_step: str,
|
||||
repo_visible: bool | None = None,
|
||||
base_branch_visible: bool | None = None,
|
||||
head_branch_visible: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
error = (
|
||||
"Failed to open an attributed PR with open_pull_request. "
|
||||
f"Reason: {reason}. Likely cause: {likely_cause}. "
|
||||
f"Branch pushed: {owner}/{repo}:{head} "
|
||||
f"({'unknown' if branch_pushed is None else 'yes' if branch_pushed else 'no'}). "
|
||||
"PR created: no. "
|
||||
f"Action: {suggested_action}"
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"success": False,
|
||||
"error": error,
|
||||
"code": code,
|
||||
"recoverable_by_agent": False,
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"token_kind": token_kind,
|
||||
"http_status": http_status,
|
||||
"branch_pushed": branch_pushed,
|
||||
"pr_created": False,
|
||||
"failed_step": failed_step,
|
||||
"likely_cause": likely_cause,
|
||||
"suggested_action": suggested_action,
|
||||
}
|
||||
if repo_visible is not None:
|
||||
payload["repo_visible"] = repo_visible
|
||||
if base_branch_visible is not None:
|
||||
payload["base_branch_visible"] = base_branch_visible
|
||||
if head_branch_visible is not None:
|
||||
payload["head_branch_visible"] = head_branch_visible
|
||||
_record_open_pr_failure_telemetry(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _record_open_pr_failure_telemetry(payload: dict[str, Any]) -> None:
|
||||
configurable = _configurable()
|
||||
logger.warning(
|
||||
"open_pull_request_failed code=%s owner=%s repo=%s head=%s base=%s "
|
||||
"http_status=%s token_kind=%s branch_pushed=%s thread_id=%s source=%s",
|
||||
payload.get("code"),
|
||||
payload.get("owner"),
|
||||
payload.get("repo"),
|
||||
payload.get("head"),
|
||||
payload.get("base"),
|
||||
payload.get("http_status"),
|
||||
payload.get("token_kind"),
|
||||
payload.get("branch_pushed"),
|
||||
configurable.get("thread_id"),
|
||||
configurable.get("source"),
|
||||
extra={
|
||||
"open_pull_request_failure": {
|
||||
"code": payload.get("code"),
|
||||
"owner": payload.get("owner"),
|
||||
"repo": payload.get("repo"),
|
||||
"head": payload.get("head"),
|
||||
"base": payload.get("base"),
|
||||
"http_status": payload.get("http_status"),
|
||||
"token_kind": payload.get("token_kind"),
|
||||
"branch_pushed": payload.get("branch_pushed"),
|
||||
"pr_created": payload.get("pr_created"),
|
||||
"failed_step": payload.get("failed_step"),
|
||||
"thread_id": configurable.get("thread_id"),
|
||||
"source": configurable.get("source"),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _access_failure_payload(
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
token_kind: str,
|
||||
http_status: int | None,
|
||||
reason: str,
|
||||
branch_pushed: bool | None,
|
||||
failed_step: str,
|
||||
repo_visible: bool | None = None,
|
||||
base_branch_visible: bool | None = None,
|
||||
head_branch_visible: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return _failure_payload(
|
||||
code=_ACCESS_FAILURE_CODE,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=http_status,
|
||||
reason=reason,
|
||||
likely_cause=(
|
||||
"the Open SWE GitHub App or PR author token is not installed on, granted access "
|
||||
"to, or able to see this repository or one of the PR branches"
|
||||
),
|
||||
suggested_action=(
|
||||
"install or grant the Open SWE GitHub App and the triggering user's GitHub "
|
||||
"authorization access to this repository, verify the base/head branches exist, "
|
||||
"then ask Open SWE to retry opening the PR"
|
||||
),
|
||||
branch_pushed=branch_pushed,
|
||||
failed_step=failed_step,
|
||||
repo_visible=repo_visible,
|
||||
base_branch_visible=base_branch_visible,
|
||||
head_branch_visible=head_branch_visible,
|
||||
)
|
||||
|
||||
|
||||
def _branch_failure_payload(
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
token_kind: str,
|
||||
http_status: int,
|
||||
branch: str,
|
||||
branch_role: str,
|
||||
) -> dict[str, Any]:
|
||||
branch_pushed = False if branch_role == "head" else None
|
||||
return _failure_payload(
|
||||
code=_BRANCH_FAILURE_CODE,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=http_status,
|
||||
reason=f"GitHub could not see the {branch_role} branch `{branch}` before PR creation",
|
||||
likely_cause=(
|
||||
f"the {branch_role} branch does not exist on `{owner}/{repo}` or is not visible "
|
||||
"to the PR author token"
|
||||
),
|
||||
suggested_action=(
|
||||
f"push or restore the {branch_role} branch `{branch}`, ensure the Open SWE "
|
||||
"GitHub App/token can see it, then ask Open SWE to retry opening the PR"
|
||||
),
|
||||
branch_pushed=branch_pushed,
|
||||
failed_step=f"preflight_{branch_role}_branch",
|
||||
repo_visible=True,
|
||||
base_branch_visible=False if branch_role == "base" else True,
|
||||
head_branch_visible=False if branch_role == "head" else None,
|
||||
)
|
||||
|
||||
|
||||
async def _github_get(client: httpx.AsyncClient, token: str, path: str) -> httpx.Response:
|
||||
return await client.get(f"{GITHUB_API}{path}", headers=_auth_headers(token))
|
||||
|
||||
|
||||
async def _preflight_pr_access(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
token: str,
|
||||
token_kind: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Diagnose *why* a PR creation POST failed — not an authoritative gate.
|
||||
|
||||
This runs only after the POST returns a non-201/non-422 status so it
|
||||
doesn't add latency on the happy path and avoids false positives from
|
||||
read-after-write inconsistency on just-pushed head branches.
|
||||
"""
|
||||
repo_resp = await _github_get(client, token, f"/repos/{owner}/{repo}")
|
||||
if repo_resp.status_code in {403, 404}:
|
||||
return _access_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=repo_resp.status_code,
|
||||
reason=f"GitHub returned {repo_resp.status_code} while checking repository access",
|
||||
branch_pushed=None,
|
||||
failed_step="preflight_repo",
|
||||
repo_visible=False,
|
||||
)
|
||||
if repo_resp.status_code != 200:
|
||||
return _failure_payload(
|
||||
code=_PREFLIGHT_FAILURE_CODE,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=repo_resp.status_code,
|
||||
reason=(
|
||||
f"GitHub returned {repo_resp.status_code} while checking repository access: "
|
||||
f"{_github_message(repo_resp)}"
|
||||
),
|
||||
likely_cause="GitHub repository access preflight failed before PR creation",
|
||||
suggested_action="check GitHub availability and repository access, then retry",
|
||||
branch_pushed=None,
|
||||
failed_step="preflight_repo",
|
||||
repo_visible=None,
|
||||
)
|
||||
|
||||
base_resp = await _github_get(
|
||||
client, token, f"/repos/{owner}/{repo}/branches/{quote(base, safe='')}"
|
||||
)
|
||||
if base_resp.status_code == 404:
|
||||
return _branch_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=base_resp.status_code,
|
||||
branch=base,
|
||||
branch_role="base",
|
||||
)
|
||||
if base_resp.status_code in {401, 403}:
|
||||
return _access_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=base_resp.status_code,
|
||||
reason=f"GitHub returned {base_resp.status_code} while checking base branch access",
|
||||
branch_pushed=None,
|
||||
failed_step="preflight_base_branch",
|
||||
repo_visible=True,
|
||||
base_branch_visible=False,
|
||||
)
|
||||
if base_resp.status_code != 200:
|
||||
return _failure_payload(
|
||||
code=_PREFLIGHT_FAILURE_CODE,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=base_resp.status_code,
|
||||
reason=(
|
||||
f"GitHub returned {base_resp.status_code} while checking base branch access: "
|
||||
f"{_github_message(base_resp)}"
|
||||
),
|
||||
likely_cause="GitHub branch access preflight failed before PR creation",
|
||||
suggested_action="check GitHub availability and branch access, then retry",
|
||||
branch_pushed=None,
|
||||
failed_step="preflight_base_branch",
|
||||
repo_visible=True,
|
||||
base_branch_visible=None,
|
||||
)
|
||||
|
||||
head_branch = _head_branch_for_repo(owner, head)
|
||||
if head_branch is None:
|
||||
return None
|
||||
head_resp = await _github_get(
|
||||
client, token, f"/repos/{owner}/{repo}/branches/{quote(head_branch, safe='')}"
|
||||
)
|
||||
if head_resp.status_code == 404:
|
||||
return _branch_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=head_resp.status_code,
|
||||
branch=head_branch,
|
||||
branch_role="head",
|
||||
)
|
||||
if head_resp.status_code in {401, 403}:
|
||||
return _access_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=head_resp.status_code,
|
||||
reason=f"GitHub returned {head_resp.status_code} while checking head branch access",
|
||||
branch_pushed=False,
|
||||
failed_step="preflight_head_branch",
|
||||
repo_visible=True,
|
||||
base_branch_visible=True,
|
||||
head_branch_visible=False,
|
||||
)
|
||||
if head_resp.status_code != 200:
|
||||
return _failure_payload(
|
||||
code=_PREFLIGHT_FAILURE_CODE,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=token_kind,
|
||||
http_status=head_resp.status_code,
|
||||
reason=(
|
||||
f"GitHub returned {head_resp.status_code} while checking head branch access: "
|
||||
f"{_github_message(head_resp)}"
|
||||
),
|
||||
likely_cause="GitHub branch access preflight failed before PR creation",
|
||||
suggested_action="check GitHub availability and branch access, then retry",
|
||||
branch_pushed=None,
|
||||
failed_step="preflight_head_branch",
|
||||
repo_visible=True,
|
||||
base_branch_visible=True,
|
||||
head_branch_visible=None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _find_existing_pr(
|
||||
client: httpx.AsyncClient, token: str, owner: str, repo: str, head: str
|
||||
) -> dict[str, Any] | None:
|
||||
|
|
@ -269,10 +627,20 @@ async def _open_pull_request(
|
|||
) -> dict[str, Any]:
|
||||
token, kind = await _resolve_pr_author_token()
|
||||
if not token:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No GitHub token available to open the pull request.",
|
||||
}
|
||||
return _failure_payload(
|
||||
code="no_github_token",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=kind,
|
||||
http_status=None,
|
||||
reason="No GitHub token was available to open the pull request",
|
||||
likely_cause="the triggering user is not authorized and no GitHub App token is available",
|
||||
suggested_action="connect GitHub authorization or install/grant the Open SWE GitHub App, then retry",
|
||||
branch_pushed=None,
|
||||
failed_step="resolve_pr_author_token",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
body = await _maybe_append_references(client, token, owner, repo, body)
|
||||
|
|
@ -326,10 +694,60 @@ async def _open_pull_request(
|
|||
"token_kind": kind,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"GitHub returned {resp.status_code}: {resp.text}",
|
||||
}
|
||||
# POST failed — run preflight diagnostics to understand why, so the
|
||||
# agent gets an actionable diagnosis instead of a raw HTTP status.
|
||||
# Preflight runs *after* the POST so a just-pushed branch that is
|
||||
# momentarily invisible to GitHub's ref endpoints does not cause a
|
||||
# false-positive preflight failure on what would have been a successful
|
||||
# PR creation.
|
||||
diagnostic = await _preflight_pr_access(
|
||||
client=client,
|
||||
token=token,
|
||||
token_kind=kind,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
)
|
||||
if diagnostic is not None:
|
||||
return diagnostic
|
||||
|
||||
# Preflight found nothing — surface the raw POST failure.
|
||||
if resp.status_code == 404:
|
||||
return _access_failure_payload(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=kind,
|
||||
http_status=resp.status_code,
|
||||
reason="GitHub returned 404 while creating the pull request",
|
||||
branch_pushed=True,
|
||||
failed_step="create_pull_request",
|
||||
repo_visible=True,
|
||||
base_branch_visible=True,
|
||||
head_branch_visible=True
|
||||
if _head_branch_for_repo(owner, head) is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
return _failure_payload(
|
||||
code="github_pr_create_failed",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
token_kind=kind,
|
||||
http_status=resp.status_code,
|
||||
reason=f"GitHub returned {resp.status_code} while creating the pull request: {_github_message(resp)}",
|
||||
likely_cause="GitHub rejected the pull request creation request",
|
||||
suggested_action="inspect the GitHub error, correct the branch or repository state, then retry",
|
||||
branch_pushed=True,
|
||||
failed_step="create_pull_request",
|
||||
repo_visible=True,
|
||||
base_branch_visible=True,
|
||||
head_branch_visible=True if _head_branch_for_repo(owner, head) is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def open_pull_request(
|
||||
|
|
@ -364,7 +782,8 @@ async def open_pull_request(
|
|||
Returns:
|
||||
On success: {"success": True, "created": bool, "url": str, "number": int,
|
||||
"author": str}. ``created`` is False when an open PR already existed.
|
||||
On failure: {"success": False, "error": str}.
|
||||
On failure: {"success": False, "error": str, "code": str,
|
||||
"recoverable_by_agent": False, "pr_created": False, ...}.
|
||||
"""
|
||||
return await _open_pull_request(
|
||||
owner=owner,
|
||||
|
|
|
|||
|
|
@ -26,20 +26,35 @@ GITHUB_APP_INSTALLATION_ID = os.environ.get("GITHUB_APP_INSTALLATION_ID", "")
|
|||
# 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)
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
||||
# Granted on every installation at install time, so a token scoped to these
|
||||
# always mints — the terminal rung of the fallback ladder.
|
||||
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
||||
"contents": "write",
|
||||
"pull_requests": "write",
|
||||
"issues": "write",
|
||||
"checks": "write",
|
||||
}
|
||||
# `actions:read` (CI-log reads) is a later addition an installation may not have
|
||||
# accepted. GitHub 422s a mint that requests an ungranted permission, so
|
||||
# ``_resolve_proxy_token`` walks ``PROXY_TOKEN_PERMISSION_LADDER`` high→low and
|
||||
# degrades to the guaranteed core scope instead of failing the whole run.
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
||||
**BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
**CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
"actions": "read",
|
||||
}
|
||||
# `workflows:write` is deliberately kept OUT of the standing runtime scope: the
|
||||
# sandbox proxy token cannot push `.github/workflows/*` during normal operation.
|
||||
# ``WorkflowPushGuardMiddleware`` mints this elevated scope only for an approved
|
||||
# HITL workflow-file push and restores the standing scope afterwards, so token
|
||||
# scope stays a backstop for the approval control rather than a standing grant.
|
||||
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS: dict[str, str] = {
|
||||
**BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
**CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
"workflows": "write",
|
||||
}
|
||||
PROXY_TOKEN_PERMISSION_LADDER: tuple[dict[str, str], ...] = (
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
|
||||
PermissionMap = Mapping[str, str]
|
||||
PermissionKey = tuple[tuple[str, str], ...]
|
||||
|
|
@ -174,9 +189,32 @@ async def get_github_app_installation_token_with_expiry(
|
|||
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 httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status == 422:
|
||||
# Expected when the installation hasn't granted a requested permission:
|
||||
# the ladder caller descends to a smaller scope. Not a transient error,
|
||||
# so keep it at debug even on the terminal rung.
|
||||
logger.debug(
|
||||
"GitHub App token mint rejected for requested scope (HTTP 422)", exc_info=True
|
||||
)
|
||||
elif log_errors:
|
||||
logger.exception("Failed to get GitHub App installation token")
|
||||
else:
|
||||
# A non-422 failure is NOT a missing grant; surface it so a transient
|
||||
# error doesn't silently downscope a run that would otherwise qualify.
|
||||
logger.warning(
|
||||
"GitHub App token mint failed (HTTP %s); scope may degrade transiently",
|
||||
status,
|
||||
exc_info=True,
|
||||
)
|
||||
return None, None
|
||||
except Exception:
|
||||
if log_errors:
|
||||
logger.exception("Failed to get GitHub App installation token")
|
||||
else:
|
||||
logger.debug("Failed to get GitHub App installation token", exc_info=True)
|
||||
logger.warning(
|
||||
"GitHub App token mint failed unexpectedly; scope may degrade transiently",
|
||||
exc_info=True,
|
||||
)
|
||||
return None, None
|
||||
|
|
|
|||
|
|
@ -91,6 +91,23 @@ def clear_proxy_token_expiry(thread_id: str | None) -> None:
|
|||
_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 ()
|
||||
|
|
|
|||
|
|
@ -68,17 +68,17 @@
|
|||
{"sha": "52fe2916", "pr": 1698, "subject": "feat: add PR review link route (#1698)", "disposition": "landed", "reason": "cherry-picked (-x) in #127 (PR review link route)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "5f7f5fbd", "pr": 1697, "subject": "fix: Reduce graph import and loader startup latency (#1697)", "disposition": "landed", "reason": "import-hygiene refactor; cross-cutting, references many deferred upstream-only modules", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only.", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T20:00:00Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "deferred", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "landed", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-13T17:16:20Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "landed", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "landed", "reason": "Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5.", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-10T17:07:19Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "landed", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-13T17:06:27Z"}
|
||||
{"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "landed", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "feature/port-linear-pr-author", "local_sha": null, "updated": "2026-07-13T17:17:12Z"}
|
||||
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "landed", "reason": "Ported to dev via feat/thread-sandbox-id-sidebar.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-10T16:48:54Z"}
|
||||
{"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "landed", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "f53caff1", "pr": 1701, "subject": "fix: fall back to core GitHub App scope when optional grants missing (#1701)", "disposition": "deferred", "reason": "FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token", "branch": "github-app-scope", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "f53caff1", "pr": 1701, "subject": "fix: fall back to core GitHub App scope when optional grants missing (#1701)", "disposition": "landed", "reason": "Ported as Option A: workflows:write kept OUT of standing scope, minted only transiently by the workflow-push guard (security-reviewed); PR #181", "branch": "chore/port-github-app-scope-fallback", "local_sha": null, "updated": "2026-07-13T18:15:30Z"}
|
||||
{"sha": "73a9e8b5", "pr": 1693, "subject": "chore(deps): bump the minor-and-patch group across 1 directory with 19 updates (#1693)", "disposition": "wont-merge", "reason": "dev at-or-ahead on 17/19; group fights dev's pinned langsmith==0.9.7 (#115) and carries an upstream plan-route test", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "9cd7e464", "pr": 1700, "subject": "Fix workflow approval visibility (#1700)", "disposition": "wont-merge", "reason": "superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "22e024cb", "pr": 1704, "subject": "fix: link issue PRs and prompt repo conventions (#1704)", "disposition": "deferred", "reason": "issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113", "branch": "webhook-issue-linking", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
|
|
|
|||
|
|
@ -53,14 +53,17 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `52fe2916` | #1698 | feat: add PR review link route (#1698) | Landed | cherry-picked (-x) in #127 (PR review link route) | reviewer-misc |
|
||||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Landed | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only. | plan-approval |
|
||||
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Landed | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Landed | Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5. | fable-admin-toggle |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Landed | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
|
||||
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Landed | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | feature/port-linear-pr-author |
|
||||
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Landed | Ported to dev via feat/thread-sandbox-id-sidebar. | dashboard-ui |
|
||||
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
|
||||
| `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Landed | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Landed | Ported as Option A: workflows:write kept OUT of standing scope, minted only transiently by the workflow-push guard (security-reviewed); PR #181 | chore/port-github-app-scope-fallback |
|
||||
| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | |
|
||||
| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | |
|
||||
| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | |
|
||||
|
|
@ -90,9 +93,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `c0a7e93e` | #1691 | fix: reconnect sandbox backend on resumed runs (#1691) | Deferred | reconnect proxy (has_backend/reconnect); assumes async create_sandbox | sandbox-refactor |
|
||||
| `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor |
|
||||
| `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor |
|
||||
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
|
||||
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
|
||||
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope |
|
||||
| `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking |
|
||||
| `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker |
|
||||
| `62e0ca2d` | #1709 | fix: stale admin model defaults after model upgrades (#1709) | Deferred | stale admin model-default cleanup in team_settings after model upgrades; applies to fork's default-model resolution. | model-picker |
|
||||
|
|
|
|||
|
|
@ -124,6 +124,15 @@ def _diff_files(base: str, head: str) -> list[dict[str, Any]]:
|
|||
return files
|
||||
|
||||
|
||||
def branch_exists(branch: str) -> bool:
|
||||
"""Check whether a branch exists in the bare remote (the fake GitHub)."""
|
||||
try:
|
||||
_git("--git-dir", str(BARE_REMOTE), "rev-parse", "--verify", f"refs/heads/{branch}")
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def create_pull(
|
||||
owner: str, repo: str, *, head: str, base: str, title: str, body: str, draft: bool
|
||||
) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -427,6 +427,13 @@ async def gh_get_repo(owner: str, repo: str) -> JSONResponse:
|
|||
return JSONResponse({"full_name": f"{owner}/{repo}", "private": False})
|
||||
|
||||
|
||||
@app.get("/fake-gh/repos/{owner}/{repo}/branches/{branch:path}")
|
||||
async def gh_get_branch(owner: str, repo: str, branch: str) -> JSONResponse: # noqa: ARG001
|
||||
if not fakes.branch_exists(branch):
|
||||
return JSONResponse({"message": "Branch not found"}, status_code=404)
|
||||
return JSONResponse({"name": branch, "commit": {"sha": "deadbeef"}})
|
||||
|
||||
|
||||
@app.get("/fake-gh/repos/{owner}/{repo}/pulls")
|
||||
async def gh_list_pulls(owner: str, repo: str) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse([])
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agent.utils import github_app
|
||||
|
|
@ -148,12 +150,103 @@ async def test_installation_token_can_be_scoped_to_repository_ids(
|
|||
|
||||
|
||||
def test_runtime_proxy_token_permissions_include_optional_read_only_actions() -> None:
|
||||
assert "actions" not in github_app.BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "actions" not in github_app.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert github_app.RUNTIME_PROXY_TOKEN_PERMISSIONS["actions"] == "read"
|
||||
assert github_app.RUNTIME_PROXY_TOKEN_PERMISSIONS.get("actions") != "write"
|
||||
assert "actions" not in github_app.WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
|
||||
|
||||
def test_workflows_write_is_never_in_the_standing_scope() -> None:
|
||||
"""workflows:write is guard-only; the standing token must never carry it, so
|
||||
token scope stays a backstop for the workflow-push HITL approval control."""
|
||||
assert "workflows" not in github_app.RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "workflows" not in github_app.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert github_app.WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS["workflows"] == "write"
|
||||
assert all("workflows" not in scope for scope in github_app.PROXY_TOKEN_PERMISSION_LADDER)
|
||||
|
||||
|
||||
def test_core_proxy_token_permissions_exclude_optional_grants() -> None:
|
||||
"""The terminal ladder rung must only ask for install-time permissions."""
|
||||
core = github_app.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "workflows" not in core
|
||||
assert "actions" not in core
|
||||
assert core["contents"] == "write"
|
||||
|
||||
|
||||
def test_proxy_token_ladder_descends_to_core() -> None:
|
||||
"""Ladder goes most→least privileged so a missing grant degrades gracefully."""
|
||||
ladder = github_app.PROXY_TOKEN_PERMISSION_LADDER
|
||||
assert ladder == (
|
||||
github_app.RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
github_app.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
assert [len(scope) for scope in ladder] == sorted(
|
||||
(len(scope) for scope in ladder), reverse=True
|
||||
)
|
||||
|
||||
|
||||
class _HTTPStatusErrorClient:
|
||||
"""Raises an ``HTTPStatusError`` with a configurable status on mint."""
|
||||
|
||||
status = 500
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _HTTPStatusErrorClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, **kwargs: Any) -> Any:
|
||||
request = httpx.Request("POST", url)
|
||||
response = httpx.Response(type(self).status, request=request)
|
||||
raise httpx.HTTPStatusError("mint failed", request=request, response=response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_grant_422_descends_quietly(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A 422 (ungranted permission) is the ladder's expected descend signal, so it
|
||||
must not be logged as a transient failure even on a non-terminal rung."""
|
||||
|
||||
class Client(_HTTPStatusErrorClient):
|
||||
status = 422
|
||||
|
||||
_configure(monkeypatch, Client)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="agent.utils.github_app"):
|
||||
token, _ = await github_app.get_github_app_installation_token_with_expiry(
|
||||
permissions={"actions": "read"}, log_errors=False
|
||||
)
|
||||
|
||||
assert token is None
|
||||
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_mint_error_warns_even_when_errors_suppressed(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A non-422 failure is not a missing grant; it must surface at WARNING even on
|
||||
a non-terminal rung so a blip doesn't silently downscope a whole run."""
|
||||
|
||||
class Client(_HTTPStatusErrorClient):
|
||||
status = 503
|
||||
|
||||
_configure(monkeypatch, Client)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="agent.utils.github_app"):
|
||||
token, _ = await github_app.get_github_app_installation_token_with_expiry(
|
||||
permissions={"actions": "read"}, log_errors=False
|
||||
)
|
||||
|
||||
assert token is None
|
||||
assert any(r.levelno == logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_token_includes_permissions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(github_app, "GITHUB_APP_ID", "1")
|
||||
|
|
|
|||
|
|
@ -148,11 +148,21 @@ def test_construct_system_prompt_does_not_require_pr_for_questions() -> None:
|
|||
|
||||
assert "Do not create commits, branches, or pull requests for questions" in prompt
|
||||
assert "For information-only requests" in prompt
|
||||
assert "check them out before answering" in prompt
|
||||
assert "answer fully inline" in prompt
|
||||
assert "open or update a draft PR when the user asks for one" in prompt
|
||||
assert "Always Create PRs Policy Override" not in prompt
|
||||
assert "Always push, open/update the draft PR" not in prompt
|
||||
|
||||
|
||||
def test_shared_base_summarizes_slack_information_answers() -> None:
|
||||
from agent.prompt import OPEN_SWE_SHARED_BASE
|
||||
|
||||
assert "Slack-triggered information-only answers" in OPEN_SWE_SHARED_BASE
|
||||
assert "post only a concise summary" in OPEN_SWE_SHARED_BASE
|
||||
assert "complete answer inline" in OPEN_SWE_SHARED_BASE
|
||||
|
||||
|
||||
def test_construct_system_prompt_includes_always_create_prs_override() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace", create_prs=True)
|
||||
|
||||
|
|
@ -175,6 +185,15 @@ def test_construct_system_prompt_forbids_force_push() -> None:
|
|||
assert "git pull --rebase origin <branch>" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_forbids_pr_creation_fallbacks() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
assert '"404"/"Not Found" from `open_pull_request`' in prompt
|
||||
assert "do not retry via `gh pr create`" in prompt
|
||||
assert "`gh api repos/.../pulls`" in prompt
|
||||
assert "direct REST `POST /repos/.../pulls`" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_emits_no_attribution_when_identity_present() -> None:
|
||||
identity = CollaboratorIdentity(
|
||||
display_name="octocat",
|
||||
|
|
|
|||
|
|
@ -59,11 +59,12 @@ class _FakeClient:
|
|||
return self._post
|
||||
|
||||
async def get(
|
||||
self, url: str, *, headers: dict[str, str], params: dict[str, str]
|
||||
self, url: str, *, headers: dict[str, str], params: dict[str, str] | None = None
|
||||
) -> _FakeResponse:
|
||||
self.get_calls.append({"url": url, "headers": headers, "params": params})
|
||||
assert self._get is not None
|
||||
return self._get
|
||||
if self._get is not None:
|
||||
return self._get
|
||||
return _FakeResponse(200, {"name": "ok"})
|
||||
|
||||
|
||||
class _RoutingClient:
|
||||
|
|
@ -94,7 +95,7 @@ class _RoutingClient:
|
|||
for needle, resp in self._get_routes.items():
|
||||
if needle in url:
|
||||
return resp
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
return _FakeResponse(200, {"name": "ok"})
|
||||
|
||||
|
||||
def _install_client(monkeypatch: pytest.MonkeyPatch, client: _FakeClient | _RoutingClient) -> None:
|
||||
|
|
@ -300,7 +301,8 @@ def test_returns_existing_pr_on_422(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert result["success"] is True
|
||||
assert result["created"] is False
|
||||
assert result["number"] == 9
|
||||
assert client.get_calls[0]["params"] == {
|
||||
pr_lookup = [call for call in client.get_calls if call["params"]]
|
||||
assert pr_lookup[0]["params"] == {
|
||||
"head": "langchain-ai:open-swe/feature",
|
||||
"state": "open",
|
||||
}
|
||||
|
|
@ -314,15 +316,74 @@ def test_error_surfaced_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
monkeypatch.setattr(profiles, "get_valid_access_token", lambda *_a, **_k: _coro("user-tok"))
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", lambda: _coro("bot"))
|
||||
|
||||
client = _FakeClient(post=_FakeResponse(403, text="Resource not accessible"))
|
||||
client = _FakeClient(post=_FakeResponse(403, {"message": "Resource not accessible"}))
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "github_pr_create_failed"
|
||||
assert result["recoverable_by_agent"] is False
|
||||
assert result["pr_created"] is False
|
||||
assert "403" in result["error"]
|
||||
|
||||
|
||||
def test_404_create_returns_actionable_access_diagnostic(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117", "thread_id": "t1"})
|
||||
_stub_token(monkeypatch)
|
||||
client = _FakeClient(post=_FakeResponse(404, {"message": "Not Found"}))
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "github_app_access_missing_or_repo_not_found"
|
||||
assert result["recoverable_by_agent"] is False
|
||||
assert result["owner"] == "langchain-ai"
|
||||
assert result["repo"] == "open-swe"
|
||||
assert result["head"] == "open-swe/feature"
|
||||
assert result["base"] == "main"
|
||||
assert result["branch_pushed"] is True
|
||||
assert result["pr_created"] is False
|
||||
assert "install or grant" in result["suggested_action"]
|
||||
assert "PR created: no" in result["error"]
|
||||
assert (
|
||||
"open_pull_request_failed code=github_app_access_missing_or_repo_not_found" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_head_branch_404_reports_branch_not_pushed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Preflight runs as a diagnostic after the POST fails — the POST is attempted
|
||||
first so a just-pushed branch that momentarily 404s from GitHub's ref
|
||||
endpoints doesn't cause a false-positive preflight failure."""
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
_stub_token(monkeypatch)
|
||||
client = _RoutingClient(
|
||||
post=_FakeResponse(422, {"message": "Validation failed"}),
|
||||
get_routes={
|
||||
"/repos/langchain-ai/open-swe/branches/main": _FakeResponse(200, {"name": "main"}),
|
||||
"/repos/langchain-ai/open-swe/branches/open-swe%2Ffeature": _FakeResponse(
|
||||
404, {"message": "Branch not found"}
|
||||
),
|
||||
"/repos/langchain-ai/open-swe": _FakeResponse(200, {"private": True}),
|
||||
},
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "github_pr_branch_not_visible"
|
||||
assert result["branch_pushed"] is False
|
||||
assert result["head_branch_visible"] is False
|
||||
assert result["failed_step"] == "preflight_head_branch"
|
||||
assert len(client.post_calls) == 1
|
||||
|
||||
|
||||
async def _coro(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
|
@ -390,7 +451,7 @@ def test_appends_plan_reference_from_thread_id(monkeypatch: pytest.MonkeyPatch)
|
|||
assert client.post_calls[0]["json"]["body"] == (
|
||||
"body\n\n## References\n- Plan: https://dashboard.example/agents/thread-1/plan"
|
||||
)
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_omits_plan_reference_when_no_plan_exists(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -405,7 +466,7 @@ def test_omits_plan_reference_when_no_plan_exists(monkeypatch: pytest.MonkeyPatc
|
|||
_open_with_body("body")
|
||||
|
||||
assert client.post_calls[0]["json"]["body"] == "body"
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_omits_plan_reference_when_plan_markdown_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -420,7 +481,7 @@ def test_omits_plan_reference_when_plan_markdown_empty(monkeypatch: pytest.Monke
|
|||
_open_with_body("body")
|
||||
|
||||
assert client.post_calls[0]["json"]["body"] == "body"
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_omits_plan_reference_when_store_lookup_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -439,7 +500,7 @@ def test_omits_plan_reference_when_store_lookup_fails(monkeypatch: pytest.Monkey
|
|||
_open_with_body("body")
|
||||
|
||||
assert client.post_calls[0]["json"]["body"] == "body"
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_plan_reference_survives_source_reference_failure(
|
||||
|
|
@ -468,7 +529,7 @@ def test_plan_reference_survives_source_reference_failure(
|
|||
|
||||
sent_body = client.post_calls[0]["json"]["body"]
|
||||
assert "- Plan: https://dashboard.example/agents/thread-1/plan" in sent_body
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_no_reference_for_public_repo(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -558,7 +619,7 @@ def test_skips_append_when_no_source_context(monkeypatch: pytest.MonkeyPatch) ->
|
|||
_open_with_body("body")
|
||||
|
||||
assert client.post_calls[0]["json"]["body"] == "body"
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_does_not_duplicate_existing_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -577,7 +638,7 @@ def test_does_not_duplicate_existing_references(monkeypatch: pytest.MonkeyPatch)
|
|||
_open_with_body("body\n\n## References\n- existing")
|
||||
|
||||
assert client.post_calls[0]["json"]["body"] == "body\n\n## References\n- existing"
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls
|
||||
|
||||
|
||||
def test_derive_pr_state_prefers_merged() -> None:
|
||||
|
|
@ -594,3 +655,44 @@ def test_derive_pr_state_draft() -> None:
|
|||
|
||||
def test_derive_pr_state_open() -> None:
|
||||
assert opr.derive_pr_state(state="open", merged=False, draft=False) == "open"
|
||||
|
||||
|
||||
def test_happy_path_skips_preflight_branch_gets(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""POST-first: when the PR is created successfully no preflight GETs
|
||||
are made to /branches/... endpoints — only reference / plan checks."""
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
monkeypatch.setattr(opr, "_resolve_pr_author_token", lambda: _coro(("tok", "user")))
|
||||
|
||||
client = _RoutingClient(
|
||||
post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}),
|
||||
get_routes={"/repos/langchain-ai/open-swe": _FakeResponse(200, {"private": False})},
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is True
|
||||
branch_gets = [c for c in client.get_calls if "/branches/" in c["url"]]
|
||||
assert branch_gets == []
|
||||
|
||||
|
||||
def test_post_failure_diagnoses_via_preflight(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When the POST fails with a non-201/non-422 status, the preflight runs
|
||||
as a diagnostic and its result is returned (not the raw POST failure)."""
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
_stub_token(monkeypatch)
|
||||
|
||||
client = _RoutingClient(
|
||||
post=_FakeResponse(403, {"message": "Resource not accessible"}),
|
||||
get_routes={
|
||||
"/repos/langchain-ai/open-swe": _FakeResponse(403, {"message": "Not Found"}),
|
||||
},
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["code"] == "github_app_access_missing_or_repo_not_found"
|
||||
assert result["failed_step"] == "preflight_repo"
|
||||
assert result["repo_visible"] is False
|
||||
|
|
|
|||
76
tests/test_pr_creation_guard.py
Normal file
76
tests/test_pr_creation_guard.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
from agent.middleware.pr_creation_guard import (
|
||||
PullRequestCreationGuardMiddleware,
|
||||
is_pr_creation_fallback_command,
|
||||
)
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, command: str) -> None:
|
||||
self.tool_call = {
|
||||
"name": "execute",
|
||||
"args": {"command": command},
|
||||
"id": "call-1",
|
||||
}
|
||||
|
||||
|
||||
async def _handler(_request: Any) -> ToolMessage:
|
||||
return ToolMessage(content="allowed", tool_call_id="call-1")
|
||||
|
||||
|
||||
def test_detects_pr_creation_fallback_commands() -> None:
|
||||
assert is_pr_creation_fallback_command("GH_TOKEN=dummy gh pr create --draft")
|
||||
assert is_pr_creation_fallback_command(
|
||||
"gh api repos/langchain-ai/open-swe/pulls -X POST -f title=x"
|
||||
)
|
||||
assert is_pr_creation_fallback_command(
|
||||
"gh api -X POST repos/langchain-ai/open-swe/pulls -f title=x"
|
||||
)
|
||||
assert is_pr_creation_fallback_command(
|
||||
"GH_TOKEN=dummy gh api -X POST repos/langchain-ai/open-swe/pulls -f title=x"
|
||||
)
|
||||
assert is_pr_creation_fallback_command(
|
||||
"curl -X POST https://api.github.com/repos/langchain-ai/open-swe/pulls -d '{}'"
|
||||
)
|
||||
|
||||
|
||||
def test_allows_safe_pr_commands() -> None:
|
||||
assert not is_pr_creation_fallback_command("GH_TOKEN=dummy gh pr view 1 --json url")
|
||||
assert not is_pr_creation_fallback_command("gh pr list --head open-swe/foo")
|
||||
assert not is_pr_creation_fallback_command("gh pr edit 1 --add-label ready")
|
||||
assert not is_pr_creation_fallback_command("gh pr comment 1 --body done")
|
||||
|
||||
|
||||
async def test_middleware_blocks_execute_pr_creation_fallbacks() -> None:
|
||||
for command in (
|
||||
"GH_TOKEN=dummy gh pr create --draft",
|
||||
"gh api repos/langchain-ai/open-swe/pulls -X POST -f title=x",
|
||||
"GH_TOKEN=dummy gh api -X POST repos/langchain-ai/open-swe/pulls -f title=x",
|
||||
"curl -X POST https://api.github.com/repos/langchain-ai/open-swe/pulls -d '{}'",
|
||||
):
|
||||
result = await PullRequestCreationGuardMiddleware().awrap_tool_call(
|
||||
_Request(command), _handler
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.status == "error"
|
||||
payload = json.loads(str(result.content))
|
||||
assert payload["code"] == "pr_creation_fallback_blocked"
|
||||
assert payload["recoverable_by_agent"] is False
|
||||
assert "open_pull_request" in payload["error"]
|
||||
assert payload["blocked_command"] == command
|
||||
|
||||
|
||||
async def test_middleware_allows_safe_pr_view() -> None:
|
||||
result = await PullRequestCreationGuardMiddleware().awrap_tool_call(
|
||||
_Request("GH_TOKEN=dummy gh pr view 1 --json url"), _handler
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.content == "allowed"
|
||||
|
|
@ -10,7 +10,8 @@ import pytest
|
|||
|
||||
from agent.integrations.langsmith import _configure_github_proxy
|
||||
from agent.utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
PROXY_TOKEN_PERMISSION_LADDER,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
|
||||
|
|
@ -233,16 +234,70 @@ class TestCreateSandboxWithProxy:
|
|||
)
|
||||
assert mock_get_token.await_args_list[0].kwargs["log_errors"] is False
|
||||
assert mock_get_token.await_args_list[1].kwargs["permissions"] == (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
mock_proxy.assert_called_once_with("sandbox-123", "ghs_install")
|
||||
mock_record.assert_called_once_with(
|
||||
"thread-123",
|
||||
"expires",
|
||||
repositories=None,
|
||||
permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
permissions=CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ladder_walks_every_rung_and_never_requests_workflows(self) -> None:
|
||||
"""The standing ladder degrades RUNTIME→CORE and must never ask for
|
||||
workflows:write; that grant is minted only transiently by the push guard."""
|
||||
with (
|
||||
patch(
|
||||
"agent.server.get_github_app_installation_token_with_expiry",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[(None, None), ("ghs_install", "expires")],
|
||||
) as mock_get_token,
|
||||
patch("agent.server.create_sandbox") as mock_create,
|
||||
patch("agent.server._configure_github_proxy") as mock_proxy,
|
||||
patch("agent.server.record_proxy_token_expiry") as mock_record,
|
||||
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith", "LANGSMITH_API_KEY": "ls-key"}),
|
||||
):
|
||||
mock_create.return_value = MagicMock(id="sandbox-123")
|
||||
|
||||
from agent.server import _create_sandbox_with_proxy
|
||||
|
||||
await _create_sandbox_with_proxy(thread_id="thread-123")
|
||||
|
||||
scopes = [call.kwargs["permissions"] for call in mock_get_token.await_args_list]
|
||||
assert scopes == list(PROXY_TOKEN_PERMISSION_LADDER)
|
||||
assert all("workflows" not in scope for scope in scopes)
|
||||
mock_proxy.assert_called_once_with("sandbox-123", "ghs_install")
|
||||
mock_record.assert_called_once_with(
|
||||
"thread-123",
|
||||
"expires",
|
||||
repositories=None,
|
||||
permissions=CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_only_when_even_core_scope_fails(self) -> None:
|
||||
"""A hard failure requires every ladder rung — including core — to fail."""
|
||||
with (
|
||||
patch(
|
||||
"agent.server.get_github_app_installation_token_with_expiry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None),
|
||||
) as mock_get_token,
|
||||
patch("agent.server.create_sandbox") as mock_create,
|
||||
patch("agent.server._configure_github_proxy"),
|
||||
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith", "LANGSMITH_API_KEY": "ls-key"}),
|
||||
):
|
||||
mock_create.return_value = MagicMock(id="sandbox-123")
|
||||
|
||||
from agent.server import _create_sandbox_with_proxy
|
||||
|
||||
with pytest.raises(ValueError, match="installation token is unavailable"):
|
||||
await _create_sandbox_with_proxy(thread_id="thread-123")
|
||||
|
||||
assert mock_get_token.await_count == len(PROXY_TOKEN_PERMISSION_LADDER)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_proxy_for_non_langsmith(self) -> None:
|
||||
"""Non-langsmith sandboxes should skip proxy configuration."""
|
||||
|
|
|
|||
|
|
@ -415,8 +415,50 @@ async def test_workflow_push_restoration_falls_back_when_actions_read_unavailabl
|
|||
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert refreshed[1]["actions"] == "read"
|
||||
assert refreshed[2] == guard.BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert refreshed[2] == guard.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "actions" not in refreshed[2]
|
||||
assert "workflows" not in refreshed[2]
|
||||
|
||||
|
||||
async def test_workflow_push_restores_recorded_baseline_scope(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Restore targets the run's recorded baseline scope (here: core, because the
|
||||
install never granted actions:read) — not a hardcoded RUNTIME that would 422."""
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
refreshed: list[dict[str, str]] = []
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_refresh(thread_id: str | None, *, permissions: dict[str, str]) -> bool:
|
||||
refreshed.append(dict(permissions))
|
||||
return True
|
||||
|
||||
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
monkeypatch.setattr(
|
||||
guard,
|
||||
"get_recorded_proxy_permissions",
|
||||
lambda _thread_id: dict(guard.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS),
|
||||
)
|
||||
|
||||
async def handler(_request: Any) -> ToolMessage:
|
||||
return ToolMessage(content="pushed", tool_call_id="call-1")
|
||||
|
||||
await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
|
||||
|
||||
# Elevate to workflows:write, then restore straight to the recorded core scope:
|
||||
# no spurious RUNTIME attempt (which would 422 for this install) and no false
|
||||
# "failed to downscope" error.
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert refreshed[1] == guard.CORE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "workflows" not in refreshed[1]
|
||||
assert len(refreshed) == 2
|
||||
|
||||
|
||||
async def test_non_workflow_push_runs_without_approval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue