From 2fb1122630e141d577fff61b3e119d21839f3414 Mon Sep 17 00:00:00 2001 From: "seahaven-openswe[bot]" <296972425+seahaven-openswe[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:17:00 -0400 Subject: [PATCH] feat(open-swe): open Linear-triggered PRs as the triggering user (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open Linear-triggered PRs as the triggering user Add 'linear' to the set of sources that carry a mapped GitHub login (Slack, Linear, dashboard, schedule), so Linear-triggered runs resolve the per-user OAuth token when the author_prs_as_user profile flag is enabled. Previously only Slack and dashboard runs could open PRs as the user; Linear runs always used the bot token. The Linear webhook now resolves the GitHub login from the Linear email via the same user-mapping store Slack uses, and passes it through the run configurable and thread owner metadata. Refs: 5003c953 (upstream #1683) * fix(open-swe): restrict Linear token attribution to comment author only Split actor_email (comment_author only, feeds github_login for token attribution) from user_email (full fallback chain, for display/model). This ensures a PR is never opened as a non-actor (creator/assignee). Add test_resolve_github_token_linear_defaults_to_bot to lock the security-critical default: Linear + mapped login + no opt-in → bot. --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Co-authored-by: Adam Moussa --- agent/tools/open_pull_request.py | 13 ++-- agent/utils/auth.py | 19 +++-- agent/webhooks/linear.py | 26 +++++-- docs/upstream-sync/triage.jsonl | 2 +- docs/upstream-sync/triage.md | 2 +- tests/test_auth_sources.py | 72 ++++++++++++++++- tests/test_linear_webhook_author.py | 116 ++++++++++++++++++++++++++++ tests/test_open_pull_request.py | 35 +++++++++ 8 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 tests/test_linear_webhook_author.py diff --git a/agent/tools/open_pull_request.py b/agent/tools/open_pull_request.py index 1389bcb3..2ddce332 100644 --- a/agent/tools/open_pull_request.py +++ b/agent/tools/open_pull_request.py @@ -20,7 +20,7 @@ from ..utils.slack import get_slack_permalink logger = logging.getLogger(__name__) GITHUB_API = "https://api.github.com" -_USER_TOKEN_SOURCES = ("slack", "dashboard") +_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" @@ -31,11 +31,12 @@ async def _resolve_pr_author_token() -> tuple[str | None, str]: """Return ``(token, kind)`` for opening the PR. DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the - installation token) for every source, so Slack/dashboard PRs are attributed - to the app — matching GitHub-issue runs and making the self-review 422 - impossible by construction. OPT-IN: when the triggering user's profile has - ``author_prs_as_user: true``, open Slack/dashboard PRs as that user (their - per-user OAuth token, resolved by login from the dashboard OAuth store). + installation token) for every source, so PRs are attributed to the app — + matching GitHub-issue runs and making the self-review 422 impossible by + construction. OPT-IN: when the triggering user's profile has + ``author_prs_as_user: true``, open Slack/Linear/dashboard PRs as that user + (their per-user OAuth token, resolved by login from the dashboard OAuth + store). The token is resolved by login rather than read from the shared thread metadata: Slack thread ids are shared across a conversation, so a cached diff --git a/agent/utils/auth.py b/agent/utils/auth.py index d8de4077..732cadf3 100644 --- a/agent/utils/auth.py +++ b/agent/utils/auth.py @@ -423,8 +423,10 @@ async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | No async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str | None]: """Resolve a GitHub token from the run config based on the source. - Routes to the correct auth method depending on whether the run was - triggered from GitHub (login-based) or Linear/Slack (email-based). + Routes to the correct auth method depending on the source. Sources that + carry a mapped GitHub login (Slack, Linear, dashboard, schedule) resolve a + per-user OAuth token from the dashboard store; GitHub runs are login-based; + otherwise resolution falls back to email-based auth. In bot-token-only mode (LANGSMITH_API_KEY_PROD set without X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used @@ -441,13 +443,14 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[ github_login = configurable.get("github_login") - # DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token, - # so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]` - # (deterministic; matches GitHub-issue runs; eliminates the self-review 422). - # OPT-IN: a profile with `author_prs_as_user: true` restores the per-user OAuth - # token so the run is attributed to the triggering user. + # DEFAULT: Slack/Linear/dashboard/schedule runs use the GitHub App installation + # token, so all git/gh operations + the PR come in as the app + # `seahaven-openswe[bot]` (deterministic; matches GitHub-issue runs; eliminates + # the self-review 422). OPT-IN: a profile with `author_prs_as_user: true` + # restores the per-user OAuth token so the run is attributed to the triggering + # user. if ( - source in ("slack", "dashboard", "schedule") + source in ("slack", "linear", "dashboard", "schedule") and isinstance(github_login, str) and github_login.strip() ): diff --git a/agent/webhooks/linear.py b/agent/webhooks/linear.py index 104b1b51..36ceb1ab 100644 --- a/agent/webhooks/linear.py +++ b/agent/webhooks/linear.py @@ -40,12 +40,19 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 if not full_issue: full_issue = issue_data - user_email = None user_name = None + # Actor email for token attribution: restricted to the comment author only. + # The creator/assignee fallback chain is intentionally excluded here so a PR + # is never opened as a non-actor. + actor_email = None comment_author = issue_data.get("comment_author", {}) if comment_author: - user_email = comment_author.get("email") + actor_email = comment_author.get("email") user_name = comment_author.get("name") + + # User email with full fallback chain for display, model selection, and + # @mention instructions. + user_email = actor_email if not user_email: creator = full_issue.get("creator", {}) if creator: @@ -164,13 +171,17 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 f"When you're done, commit and push your changes. {tag_instruction}" ) content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] + + # Resolve the GitHub login from the actor's Linear email via the same + # user-mapping store Slack uses, so PRs open *as the triggering user* and the + # thread is tagged for the dashboard. Restricted to the comment author so + # token attribution never falls back to creator/assignee. + mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None + image_model_override: tuple[str, str] | None = None if image_urls: image_urls = webapp.dedupe_urls(image_urls) - linear_login = ( - await webapp.resolve_login_from_email_async(user_email) if user_email else None - ) - resolved_model_id = await webapp.resolve_agent_model_id(linear_login) + resolved_model_id = await webapp.resolve_agent_model_id(mapped_login) if not webapp.model_supports_images(resolved_model_id): fallback_model_id, fallback_effort = webapp.default_vision_model_pair() webapp.logger.info( @@ -213,6 +224,8 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "user_email": user_email, "source": "linear", } + if mapped_login: + configurable["github_login"] = mapped_login if image_model_override: configurable["agent_model_id"] = image_model_override[0] configurable["agent_effort"] = image_model_override[1] @@ -221,6 +234,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 thread_id, source="linear", repo_config=repo_config, + github_login=mapped_login or "", user_email=user_email or "", title=title or identifier or "Linear issue", source_context={"linear_issue": configurable["linear_issue"]}, diff --git a/docs/upstream-sync/triage.jsonl b/docs/upstream-sync/triage.jsonl index dbfe1cc3..e390010b 100644 --- a/docs/upstream-sync/triage.jsonl +++ b/docs/upstream-sync/triage.jsonl @@ -73,7 +73,7 @@ {"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": "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": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"} +{"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"} diff --git a/docs/upstream-sync/triage.md b/docs/upstream-sync/triage.md index 9bb0ea1c..3f849cd2 100644 --- a/docs/upstream-sync/triage.md +++ b/docs/upstream-sync/triage.md @@ -58,6 +58,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `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 | @@ -92,7 +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 | -| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user | | `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 | diff --git a/tests/test_auth_sources.py b/tests/test_auth_sources.py index c19f5250..fc3d0282 100644 --- a/tests/test_auth_sources.py +++ b/tests/test_auth_sources.py @@ -183,7 +183,77 @@ def test_resolve_github_token_slack_optin_no_token_falls_back_to_bot_in_bot_only assert (token, expires_at) == ("bot-tok", None) -@pytest.mark.parametrize("source", ["github", "linear"]) +def _linear_config(github_login: str | None = "mason-gh") -> dict: + configurable: dict = { + "source": "linear", + "user_email": "mason@example.com", + "thread_id": "t1", + } + if github_login is not None: + configurable["github_login"] = github_login + return {"configurable": configurable} + + +def test_resolve_github_token_linear_optin_uses_dashboard_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _stub_dashboard_store(monkeypatch, token="user-tok") + _set_profile(monkeypatch, author_prs_as_user=True) + monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False) + + token, expires_at = asyncio.run(auth.resolve_github_token(_linear_config(), "t1")) + + assert token == "user-tok" + assert expires_at == "2099-01-01T00:00:00Z" + + +def test_resolve_github_token_linear_optin_no_token_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _stub_dashboard_store(monkeypatch, token=None) + _set_profile(monkeypatch, author_prs_as_user=True) + monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False) + + with pytest.raises(auth.GitHubUserAuthRequired): + asyncio.run(auth.resolve_github_token(_linear_config(), "t1")) + + +def test_resolve_github_token_linear_optin_no_token_falls_back_to_bot_in_bot_only_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _stub_dashboard_store(monkeypatch, token=None) + _set_profile(monkeypatch, author_prs_as_user=True) + monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True) + + async def fake_bot(thread_id: str): + return ("bot-tok", None) + + monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot) + + token, expires_at = asyncio.run(auth.resolve_github_token(_linear_config(), "t1")) + assert (token, expires_at) == ("bot-tok", None) + + +def test_resolve_github_token_linear_defaults_to_bot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # DEFAULT (no author_prs_as_user opt-in): linear runs author as the app bot, + # even when a valid per-user token and mapped login exist — so PRs can never + # be opened as a non-actor (creator/assignee). + _stub_dashboard_store(monkeypatch, token="user-tok") + _set_profile(monkeypatch, author_prs_as_user=False) + monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False) + + async def fake_bot(thread_id: str): + return ("bot-tok", None) + + monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot) + + token, _ = asyncio.run(auth.resolve_github_token(_linear_config(), "t1")) + assert token == "bot-tok" + + +@pytest.mark.parametrize("source", ["github"]) def test_resolve_github_token_bot_only_mode_non_slack_uses_bot( monkeypatch: pytest.MonkeyPatch, source: str ) -> None: diff --git a/tests/test_linear_webhook_author.py b/tests/test_linear_webhook_author.py new file mode 100644 index 00000000..e2fbd703 --- /dev/null +++ b/tests/test_linear_webhook_author.py @@ -0,0 +1,116 @@ +"""Tests for Linear webhook PR author linking (reuse of the Slack user mapping).""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, patch + +from agent.webhooks import linear as linear_webhook + + +def _full_issue(*, user_email: str | None = "zhen@example.com", user_name: str = "Zhen") -> dict: + return { + "id": "issue-1", + "title": "Link Linear PRs to author", + "description": "Do the thing", + "identifier": "OS-42", + "url": "https://linear.app/x/issue/OS-42", + "creator": {"email": user_email, "name": user_name}, + "comments": {"nodes": []}, + } + + +def _issue_data(*, user_email: str | None, user_name: str = "Zhen") -> dict: + # linear_webhook attaches comment_author to the issue dict before dispatch. + data = _full_issue(user_email=user_email, user_name=user_name) + data["comment_author"] = {"email": user_email, "name": user_name} + return data + + +def _run_process(issue_data: dict, repo_config: dict[str, str]) -> tuple[dict, dict, str | None]: + captured: dict[str, Any] = {} + + async def fake_dispatch( + thread_id, content, configurable, *, source, metadata=None, client=None + ): + captured["configurable"] = configurable + return {"run_id": "run-1"} + + async def fake_upsert( + thread_id, + *, + source, + repo_config=None, + github_login="", + user_email="", + title="", + source_context=None, + ): + captured["upsert"] = {"github_login": github_login, "user_email": user_email} + return None + + async def fake_resolve_login(email): + captured["resolved_email"] = email + return "zhen" if email == "zhen@example.com" else None + + with ( + patch.object(linear_webhook.webapp, "react_to_linear_comment", new_callable=AsyncMock), + patch.object( + linear_webhook.webapp, "generate_thread_id_from_issue", return_value="thread-1" + ), + patch.object( + linear_webhook.webapp, + "fetch_linear_issue_details", + new_callable=AsyncMock, + return_value=_full_issue(user_email=issue_data.get("comment_author", {}).get("email")), + ), + patch.object( + linear_webhook.webapp, "resolve_login_from_email_async", side_effect=fake_resolve_login + ), + patch.object(linear_webhook.webapp, "dispatch_agent_run", side_effect=fake_dispatch), + patch.object( + linear_webhook.webapp, "upsert_agent_thread_owner_metadata", side_effect=fake_upsert + ), + patch.object(linear_webhook.webapp, "post_linear_trace_comment", new_callable=AsyncMock), + ): + asyncio.run(linear_webhook.process_linear_issue(issue_data, repo_config)) + + return ( + captured.get("configurable", {}), + captured.get("upsert", {}), + captured.get("resolved_email"), + ) + + +def test_linear_configurable_carries_github_login() -> None: + configurable, _upsert, resolved_email = _run_process( + _issue_data(user_email="zhen@example.com"), + {"owner": "langchain-ai", "name": "open-swe"}, + ) + + assert resolved_email == "zhen@example.com" + assert configurable["source"] == "linear" + assert configurable["github_login"] == "zhen" + assert configurable["user_email"] == "zhen@example.com" + + +def test_linear_upsert_tags_thread_with_login() -> None: + _configurable, upsert, _email = _run_process( + _issue_data(user_email="zhen@example.com"), + {"owner": "langchain-ai", "name": "open-swe"}, + ) + + assert upsert["github_login"] == "zhen" + assert upsert["user_email"] == "zhen@example.com" + + +def test_linear_omits_login_when_unmapped() -> None: + configurable, upsert, resolved_email = _run_process( + _issue_data(user_email="nobody@example.com"), + {"owner": "langchain-ai", "name": "open-swe"}, + ) + + assert resolved_email == "nobody@example.com" + assert "github_login" not in configurable + assert upsert["github_login"] == "" diff --git a/tests/test_open_pull_request.py b/tests/test_open_pull_request.py index e30a88fd..9df1841d 100644 --- a/tests/test_open_pull_request.py +++ b/tests/test_open_pull_request.py @@ -196,6 +196,41 @@ def test_uses_user_token_for_slack_with_optin(monkeypatch: pytest.MonkeyPatch) - } +def test_uses_user_token_for_linear_with_optin(monkeypatch: pytest.MonkeyPatch) -> None: + _set_config(monkeypatch, {"source": "linear", "github_login": "johannes117"}) + _set_profile(monkeypatch, author_prs_as_user=True) + + from agent.dashboard import profiles + + async def fake_user_token(login: str, **_kw: Any) -> str | None: + assert login == "johannes117" + return "user-tok" + + monkeypatch.setattr(profiles, "get_valid_access_token", fake_user_token) + + async def fail_bot() -> str | None: + raise AssertionError("bot token should not be used when a user token exists") + + monkeypatch.setattr(opr, "get_github_app_installation_token", fail_bot) + + client = _FakeClient( + post=_FakeResponse( + 201, + {"html_url": "https://x/pull/1", "number": 1, "user": {"login": "johannes117"}}, + ) + ) + _install_client(monkeypatch, client) + + result = _open() + + assert result["success"] is True + assert result["created"] is True + assert result["url"] == "https://x/pull/1" + assert result["author"] == "johannes117" + assert result["token_kind"] == "user" + assert client.post_calls[0]["headers"]["Authorization"] == "Bearer user-tok" + + def test_falls_back_to_bot_for_github_source(monkeypatch: pytest.MonkeyPatch) -> None: _set_config(monkeypatch, {"source": "github", "github_login": "johannes117"})