From db05d5826fe646dc576d18f125b736ed74414c13 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:54:16 -0400 Subject: [PATCH] feat(open-swe): port upstream clean batch (#1775, #1785, #1789) (#215) * Fix: Fix Improper privilege management in server.py (#1789) Co-authored-by: corridor-security[bot] <203152403+corridor-security[bot]@users.noreply.github.com> (cherry picked from commit 3ea29d3f231dd66bd7627769b5659564be4525df) * Fix: Fix Stored XSS in ReplyCard.tsx (#1785) Co-authored-by: corridor-security[bot] <203152403+corridor-security[bot]@users.noreply.github.com> (cherry picked from commit 31263f832a2ecedf669eee2e27b827a358a6a4d7) * feat: add structured Linear issue filters (#1775) Co-authored-by: open-swe[bot] (cherry picked from commit b5e529252c3012818289eca1b1cdeb8014721310) * chore(triage): mark #1775 #1785 #1789 landed Move the three clean cherry-picks in this batch from deferred to landed in the upstream-sync ledger and re-render triage.md. --------- Co-authored-by: corridor-security[bot] <203152403+corridor-security[bot]@users.noreply.github.com> Co-authored-by: Johannes du Plessis Co-authored-by: open-swe[bot] --- agent/dashboard/routes.py | 13 ++- agent/tools/linear_search_issues.py | 11 +- agent/utils/github_comments.py | 3 + agent/utils/linear.py | 136 ++++++++++++++--------- agent/webhooks/common.py | 3 + docs/upstream-sync/triage.jsonl | 6 +- docs/upstream-sync/triage.md | 6 +- tests/tools/test_linear_search_issues.py | 58 +++++++++- 8 files changed, 168 insertions(+), 68 deletions(-) diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 03a93d6d..92d34594 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -6,6 +6,7 @@ import hmac import logging import os from typing import Any, Literal +from urllib.parse import urlencode import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request @@ -377,12 +378,14 @@ async def auth_login( nonce_hash=hash_state_nonce(nonce), ) redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback" - url = ( - "https://github.com/login/oauth/authorize" - f"?client_id={client_id}" - f"&redirect_uri={redirect_uri}" - f"&state={state}" + query = urlencode( + { + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + } ) + url = f"https://github.com/login/oauth/authorize?{query}" response = RedirectResponse(url, status_code=302) _set_state_cookie(response, nonce) return response diff --git a/agent/tools/linear_search_issues.py b/agent/tools/linear_search_issues.py index bb223cf7..f1259f6b 100644 --- a/agent/tools/linear_search_issues.py +++ b/agent/tools/linear_search_issues.py @@ -4,21 +4,23 @@ from ..utils.linear import search_issues async def linear_search_issues( - query: str, + query: str | None = None, team_id: str | None = None, + filters: dict[str, Any] | None = None, limit: int = 10, include_archived: bool = False, include_comments: bool = False, after: str | None = None, ) -> dict[str, Any]: - """Search Linear issues by title, description, and optionally comments. + """Search Linear issues by text, structured filters, or both. Args: - query: Free-text search query. + query: Optional free-text query over issue content. team_id: Optional team UUID used to restrict matches to that team. + filters: Optional Linear IssueFilter object for labels, state, project, assignee, and more. limit: Maximum results to return, from 1 to 50. include_archived: Whether to include archived issues. - include_comments: Whether to search issue comments in addition to issue content. + include_comments: Whether free-text search includes issue comments. after: Optional pagination cursor from a previous result's page_info.endCursor. Returns: @@ -27,6 +29,7 @@ async def linear_search_issues( return await search_issues( query=query, team_id=team_id, + filters=filters, limit=limit, include_archived=include_archived, include_comments=include_comments, diff --git a/agent/utils/github_comments.py b/agent/utils/github_comments.py index 5c494e3d..083d2112 100644 --- a/agent/utils/github_comments.py +++ b/agent/utils/github_comments.py @@ -63,6 +63,9 @@ def verify_github_signature(body: bytes, signature: str, *, secret: str) -> bool logger.warning("GITHUB_WEBHOOK_SECRET is not configured — rejecting webhook request") return False + if not signature: + return False + expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) diff --git a/agent/utils/linear.py b/agent/utils/linear.py index 5fce95b5..c6279725 100644 --- a/agent/utils/linear.py +++ b/agent/utils/linear.py @@ -133,76 +133,110 @@ async def get_issue(issue_id: str) -> dict[str, Any]: async def search_issues( - query: str, + query: str | None = None, team_id: str | None = None, + filters: dict[str, Any] | None = None, limit: int = 10, include_archived: bool = False, include_comments: bool = False, after: str | None = None, ) -> dict[str, Any]: - """Search Linear issues by free-text query.""" - query = query.strip() - if not query: - return {"error": "Search query must not be empty"} + """Search Linear issues by text, structured filters, or both.""" + query = (query or "").strip() + issue_filter = dict(filters or {}) + if team_id: + team_filter = {"team": {"id": {"eq": team_id}}} + issue_filter = {"and": [issue_filter, team_filter]} if issue_filter else team_filter + if not query and not issue_filter: + return {"error": "Search query or filters must be provided"} if not 1 <= limit <= 50: return {"error": "Search limit must be between 1 and 50"} - search_query = """ - query SearchIssues( - $query: String! - $filter: IssueFilter - $limit: Int! - $includeArchived: Boolean - $includeComments: Boolean - $after: String - ) { - searchIssues( - term: $query - filter: $filter - first: $limit - includeArchived: $includeArchived - includeComments: $includeComments - after: $after - ) { - totalCount - pageInfo { - hasNextPage - endCursor - } - nodes { - id - identifier - title - priority - priorityLabel - state { id name type } - assignee { id name email } - team { id name key } - project { id name } - labels { nodes { id name } } - createdAt - updatedAt - archivedAt - url - } + connection_fields = """ + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + identifier + title + priority + priorityLabel + state { id name type } + assignee { id name email } + team { id name key } + project { id name } + labels { nodes { id name } } + createdAt + updatedAt + archivedAt + url } - } """ - result = await _graphql_request( - search_query, - { + if query: + graphql_query = f""" + query SearchIssues( + $query: String! + $filter: IssueFilter + $limit: Int! + $includeArchived: Boolean + $includeComments: Boolean + $after: String + ) {{ + searchIssues( + term: $query + filter: $filter + first: $limit + includeArchived: $includeArchived + includeComments: $includeComments + after: $after + ) {{ + {connection_fields} + }} + }} + """ + variables = { "query": query, - "filter": {"team": {"id": {"eq": team_id}}} if team_id else None, + "filter": issue_filter or None, "limit": limit, "includeArchived": include_archived, "includeComments": include_comments, "after": after, - }, - ) + } + connection_name = "searchIssues" + else: + graphql_query = f""" + query FilterIssues( + $filter: IssueFilter! + $limit: Int! + $includeArchived: Boolean + $after: String + ) {{ + issues( + filter: $filter + first: $limit + includeArchived: $includeArchived + after: $after + ) {{ + {connection_fields} + }} + }} + """ + variables = { + "filter": issue_filter, + "limit": limit, + "includeArchived": include_archived, + "after": after, + } + connection_name = "issues" + + result = await _graphql_request(graphql_query, variables) if "error" in result: return result - search_results = result.get("searchIssues", {}) + search_results = result.get(connection_name, {}) return { "issues": search_results.get("nodes", []), "total_count": search_results.get("totalCount", 0), diff --git a/agent/webhooks/common.py b/agent/webhooks/common.py index 97296fed..c47a2bb5 100644 --- a/agent/webhooks/common.py +++ b/agent/webhooks/common.py @@ -1164,6 +1164,9 @@ def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool: logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request") return False + if not signature: + return False + expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return False diff --git a/docs/upstream-sync/triage.jsonl b/docs/upstream-sync/triage.jsonl index daa806f1..181bbb56 100644 --- a/docs/upstream-sync/triage.jsonl +++ b/docs/upstream-sync/triage.jsonl @@ -124,9 +124,9 @@ {"sha": "dccf6437", "pr": 1769, "subject": "fix: tighten sandbox config test types (#1769)", "disposition": "wont-merge", "reason": "test-only change in post-reorg path tests/sandbox/ — fork doesn't have this file (domain reorg #1726 deferred)", "branch": "", "local_sha": null, "updated": "2026-07-17T12:42:37Z"} {"sha": "c9a193e2", "pr": 1766, "subject": "chore(deps): bump mcp from 1.27.2 to 1.28.1 (#1766)", "disposition": "wont-merge", "reason": "indirect dependency bump (mcp); fork's own Dependabot handles these", "branch": "", "local_sha": null, "updated": "2026-07-17T12:42:37Z"} {"sha": "d0b63551", "pr": 1773, "subject": "fix: remove workflow push approval gating (#1773)", "disposition": "wont-merge", "reason": "Removes WorkflowPushGuardMiddleware and the proxy-token permission ladder — both actively wired in this fork (server.py middleware stack; github_app.py scoped-mint fallback). Adopting would let agent runs push .github/workflows/ changes with no human approval and mint proxy tokens at full scope unconditionally — a security-posture loosening counter to Sea Haven gating. Keep the fork's guard; skip the doc/prompt relaxation too (fork prompt documents the approval flow).", "branch": "", "local_sha": null, "updated": "2026-07-17T22:33:45Z"} -{"sha": "b5e52925", "pr": 1775, "subject": "feat: add structured Linear issue filters (#1775)", "disposition": "deferred", "reason": "Clean pick: structured filters for linear_search_issues — stacks directly on #1748 (landed PR #206); zero fork drift on all three files. Ready whenever.", "branch": "linear-tooling", "local_sha": null, "updated": "2026-07-17T22:33:45Z"} +{"sha": "b5e52925", "pr": 1775, "subject": "feat: add structured Linear issue filters (#1775)", "disposition": "landed", "reason": "Clean pick: structured filters for linear_search_issues — stacks directly on #1748 (landed PR #206); zero fork drift on all three files. Ready whenever.", "branch": "feature/upstream-clean-batch-security-linear", "local_sha": null, "updated": "2026-07-20T19:46:00Z"} {"sha": "81d544bc", "pr": 1765, "subject": "feat: Expose more specific AGENTS.md context to Open SWE reviewer (#1765)", "disposition": "deferred", "reason": "Near-clean: agents_md.py helper + tests are zero-drift; reviewer.py hunk is small (~39 lines) but lands in the fork's heavily-diverged reviewer — hand-apply the scoped_agents_md wiring onto the fork's fetch_agents_md call sites (reviewer.py ~L404/445/1031).", "branch": "reviewer-context", "local_sha": null, "updated": "2026-07-17T22:33:45Z"} {"sha": "8c8e58bc", "pr": 1776, "subject": "feat: connect automations to Slack channels (#1776)", "disposition": "deferred", "reason": "Moderate reconcile: Slack-channel wiring for automations. Fork helpers exist (post_slack_top_level_message_with_ts, generate_thread_id_from_slack_thread, slack_id_for_login); completion.py (+233 drift) and schedules.py (+167) need hand-merge; UI automations feature present. Keep backend+UI+tests as one vertical.", "branch": "automations-slack", "local_sha": null, "updated": "2026-07-17T22:33:46Z"} {"sha": "f0897479", "pr": 1778, "subject": "feat: surface context window usage in agents UI (#1778)", "disposition": "deferred", "reason": "Near-clean: context-window indicator UI is all new files (zero drift); options.py hunk must be re-keyed to the fork's Bedrock/Fireworks model map (fork model IDs differ from upstream's) — add context_window per fork entry rather than taking upstream values.", "branch": "context-usage-ui", "local_sha": null, "updated": "2026-07-17T22:33:46Z"} -{"sha": "31263f83", "pr": 1785, "subject": "Fix: Fix Stored XSS in ReplyCard.tsx (#1785)", "disposition": "deferred", "reason": "SECURITY priority, near-clean: diff is urlencode() hardening of the GitHub OAuth authorize URL in dashboard routes.py auth_login (upstream bot title says ReplyCard.tsx — mismatch, trust the diff). Fork auth_login has the identical f-string URL; hunk applies clean. Port promptly.", "branch": "sec-oauth-urlencode", "local_sha": null, "updated": "2026-07-20T18:06:01Z"} -{"sha": "3ea29d3f", "pr": 1789, "subject": "Fix: Fix Improper privilege management in server.py (#1789)", "disposition": "deferred", "reason": "SECURITY priority, near-clean: adds empty-signature reject to verify_github_signature (utils/github_comments.py) + verify_linear_signature (webhooks/common.py) (title says server.py — mismatch, trust the diff). Both fork functions match at the hunk sites; fork-only verify_jira_secret already guards empty token. Real value: None signature currently raises TypeError in compare_digest. Port BOTH hunks together.", "branch": "sec-webhook-empty-sig", "local_sha": null, "updated": "2026-07-20T18:06:01Z"} +{"sha": "31263f83", "pr": 1785, "subject": "Fix: Fix Stored XSS in ReplyCard.tsx (#1785)", "disposition": "landed", "reason": "SECURITY priority, near-clean: diff is urlencode() hardening of the GitHub OAuth authorize URL in dashboard routes.py auth_login (upstream bot title says ReplyCard.tsx — mismatch, trust the diff). Fork auth_login has the identical f-string URL; hunk applies clean. Port promptly.", "branch": "feature/upstream-clean-batch-security-linear", "local_sha": null, "updated": "2026-07-20T19:46:00Z"} +{"sha": "3ea29d3f", "pr": 1789, "subject": "Fix: Fix Improper privilege management in server.py (#1789)", "disposition": "landed", "reason": "SECURITY priority, near-clean: adds empty-signature reject to verify_github_signature (utils/github_comments.py) + verify_linear_signature (webhooks/common.py) (title says server.py — mismatch, trust the diff). Both fork functions match at the hunk sites; fork-only verify_jira_secret already guards empty token. Real value: None signature currently raises TypeError in compare_digest. Port BOTH hunks together.", "branch": "feature/upstream-clean-batch-security-linear", "local_sha": null, "updated": "2026-07-20T19:46:00Z"} diff --git a/docs/upstream-sync/triage.md b/docs/upstream-sync/triage.md index a0ad48ed..8904a119 100644 --- a/docs/upstream-sync/triage.md +++ b/docs/upstream-sync/triage.md @@ -85,6 +85,9 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `22383033` | #1758 | feat: inject extra JSON fields into sandbox create via env var (#1758) | Landed | Landed via fork PR #206, re-implemented against the fork's sync SandboxClient (upstream is async AsyncSandboxClient — retry/reconnect helpers not adopted; future picks touching them will conflict). | feature/port-upstream-clean-batch | | `e826864d` | #1760 | feat: optional separate LangSmith key/endpoint for sandboxes (#1760) | Landed | Landed via fork PR #206 on the sync client. Sandbox endpoint honors SANDBOX_LANGSMITH_ENDPOINT/LANGSMITH_ENDPOINT (LANGCHAIN_ENDPOINT alone no longer applies); new sandbox names thread-deterministic with _release_sandbox_name before create. | feature/port-upstream-clean-batch | | `dd5b7bec` | #1761 | fix: capitalize dashboard tool labels (#1761) | Landed | Landed via fork PR #206 (clean cherry-pick, stacked on #1732). | feature/port-upstream-clean-batch | +| `b5e52925` | #1775 | feat: add structured Linear issue filters (#1775) | Landed | Clean pick: structured filters for linear_search_issues — stacks directly on #1748 (landed PR #206); zero fork drift on all three files. Ready whenever. | feature/upstream-clean-batch-security-linear | +| `31263f83` | #1785 | Fix: Fix Stored XSS in ReplyCard.tsx (#1785) | Landed | SECURITY priority, near-clean: diff is urlencode() hardening of the GitHub OAuth authorize URL in dashboard routes.py auth_login (upstream bot title says ReplyCard.tsx — mismatch, trust the diff). Fork auth_login has the identical f-string URL; hunk applies clean. Port promptly. | feature/upstream-clean-batch-security-linear | +| `3ea29d3f` | #1789 | Fix: Fix Improper privilege management in server.py (#1789) | Landed | SECURITY priority, near-clean: adds empty-signature reject to verify_github_signature (utils/github_comments.py) + verify_linear_signature (webhooks/common.py) (title says server.py — mismatch, trust the diff). Both fork functions match at the hunk sites; fork-only verify_jira_secret already guards empty token. Real value: None signature currently raises TypeError in compare_digest. Port BOTH hunks together. | feature/upstream-clean-batch-security-linear | | `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 | | @@ -135,11 +138,8 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `5077e2c7` | #1735 | feat(open-swe): untagged two-party Slack replies + debounced interrupts (#1735) | Deferred | untagged two-party Slack replies + debounced interrupts (~620 LOC incl. e2e); rides fork-diverged Slack webhook stack; own branch + e2e validation | slack-untagged-replies | | `8b26819f` | #1719 | fix: offload web tool results to sandbox (#1719) | Deferred | offloads large web tool results to sandbox files; touches fork-relevant web_search/http tools; check interplay with fork sandbox lifecycle | tool-offloading | | `b7c5dbd6` | #1747 | fix: simplify Slack run links (#1747) | Deferred | simplifies Slack run links; heavy churn on fork-diverged Slack context/prompt tests | slack-tooling | -| `b5e52925` | #1775 | feat: add structured Linear issue filters (#1775) | Deferred | Clean pick: structured filters for linear_search_issues — stacks directly on #1748 (landed PR #206); zero fork drift on all three files. Ready whenever. | linear-tooling | | `81d544bc` | #1765 | feat: Expose more specific AGENTS.md context to Open SWE reviewer (#1765) | Deferred | Near-clean: agents_md.py helper + tests are zero-drift; reviewer.py hunk is small (~39 lines) but lands in the fork's heavily-diverged reviewer — hand-apply the scoped_agents_md wiring onto the fork's fetch_agents_md call sites (reviewer.py ~L404/445/1031). | reviewer-context | | `8c8e58bc` | #1776 | feat: connect automations to Slack channels (#1776) | Deferred | Moderate reconcile: Slack-channel wiring for automations. Fork helpers exist (post_slack_top_level_message_with_ts, generate_thread_id_from_slack_thread, slack_id_for_login); completion.py (+233 drift) and schedules.py (+167) need hand-merge; UI automations feature present. Keep backend+UI+tests as one vertical. | automations-slack | | `f0897479` | #1778 | feat: surface context window usage in agents UI (#1778) | Deferred | Near-clean: context-window indicator UI is all new files (zero drift); options.py hunk must be re-keyed to the fork's Bedrock/Fireworks model map (fork model IDs differ from upstream's) — add context_window per fork entry rather than taking upstream values. | context-usage-ui | -| `31263f83` | #1785 | Fix: Fix Stored XSS in ReplyCard.tsx (#1785) | Deferred | SECURITY priority, near-clean: diff is urlencode() hardening of the GitHub OAuth authorize URL in dashboard routes.py auth_login (upstream bot title says ReplyCard.tsx — mismatch, trust the diff). Fork auth_login has the identical f-string URL; hunk applies clean. Port promptly. | sec-oauth-urlencode | -| `3ea29d3f` | #1789 | Fix: Fix Improper privilege management in server.py (#1789) | Deferred | SECURITY priority, near-clean: adds empty-signature reject to verify_github_signature (utils/github_comments.py) + verify_linear_signature (webhooks/common.py) (title says server.py — mismatch, trust the diff). Both fork functions match at the hunk sites; fork-only verify_jira_secret already guards empty token. Real value: None signature currently raises TypeError in compare_digest. Port BOTH hunks together. | sec-webhook-empty-sig | _Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** (edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._ diff --git a/tests/tools/test_linear_search_issues.py b/tests/tools/test_linear_search_issues.py index 233a80bb..eabc67ea 100644 --- a/tests/tools/test_linear_search_issues.py +++ b/tests/tools/test_linear_search_issues.py @@ -66,7 +66,59 @@ async def test_search_issues_returns_results_and_pagination( } -async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_search_issues_filters_without_text(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_graphql_request( + query: str, variables: dict[str, Any] | None = None + ) -> dict[str, Any]: + captured.update({"query": query, "variables": variables}) + return { + "issues": { + "nodes": [{"id": "issue-id", "identifier": "DCD-21", "title": "Fix filters"}], + "totalCount": 1, + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + + monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request) + filters = {"labels": {"some": {"name": {"eq": "open-swe"}}}} + + result = await linear.search_issues(filters=filters, limit=1) + + assert "issues(" in captured["query"] + assert "searchIssues" not in captured["query"] + assert captured["variables"] == { + "filter": filters, + "limit": 1, + "includeArchived": False, + "after": None, + } + assert result["issues"][0]["identifier"] == "DCD-21" + + +async def test_search_issues_combines_filters_with_team(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_graphql_request( + _query: str, variables: dict[str, Any] | None = None + ) -> dict[str, Any]: + captured["variables"] = variables + return {"searchIssues": {"nodes": [], "totalCount": 0, "pageInfo": {}}} + + monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request) + filters = {"state": {"name": {"eq": "Todo"}}} + + await linear.search_issues("fix", team_id="team-id", filters=filters) + + assert captured["variables"]["filter"] == { + "and": [filters, {"team": {"id": {"eq": "team-id"}}}] + } + + +async def test_search_issues_rejects_missing_query_and_filters( + monkeypatch: pytest.MonkeyPatch, +) -> None: async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]: pytest.fail("GraphQL request should not be made") @@ -74,7 +126,7 @@ async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch result = await linear.search_issues(" ") - assert result == {"error": "Search query must not be empty"} + assert result == {"error": "Search query or filters must be provided"} @pytest.mark.parametrize("limit", [0, 51]) @@ -114,6 +166,7 @@ async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPat result = await linear_search_tool.linear_search_issues( "styling", team_id="team-id", + filters={"priority": {"eq": 1}}, limit=20, include_archived=True, include_comments=True, @@ -124,6 +177,7 @@ async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPat assert captured == { "query": "styling", "team_id": "team-id", + "filters": {"priority": {"eq": 1}}, "limit": 20, "include_archived": True, "include_comments": True,