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:
Adam Moussa 2026-07-13 14:49:06 -04:00
commit b8f08a4d7b
No known key found for this signature in database
18 changed files with 1212 additions and 67 deletions

View file

@ -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

View 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)

View file

@ -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):

View file

@ -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.**

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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 ()

View file

@ -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"}

View file

@ -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 |

View file

@ -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]:

View file

@ -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([])

View file

@ -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")

View file

@ -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",

View file

@ -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

View 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"

View file

@ -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."""

View file

@ -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: