From 8a9974c3c420e08e76e37f2708b38b51653bda0e Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:22:33 -0400 Subject: [PATCH] feat: author Slack/dashboard/schedule commits + PRs as the app by default (#57) (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: default Slack/dashboard/schedule PRs + commits to the app identity (#57) Slack/dashboard/schedule runs now author PRs and run git/gh operations as the GitHub App seahaven-openswe[bot] by default (matching GitHub-issue runs), so the self-review 422 is impossible by construction rather than guarded in the prompt. A profile flag author_prs_as_user restores per-user attribution. - open_pull_request._resolve_pr_author_token + auth.resolve_github_token: default to the installation token for these sources; per-user only when opted in. - authorship: commit identity -> seahaven-openswe[bot] (numeric noreply; accepted Vercel-resolution risk, documented inline). - self-trigger safety: INTERNAL_BOT_LOGINS + webapp/reviewer_reconcile/reply markers recognize seahaven-openswe[bot] (bot-authored events are now ours). Supersedes the prompt-only guard in #58. * fix: author commits as the app bot in the default path (SH-IDSPLIT-01) Security review found the commit identity was NOT actually unified to the bot: resolve_triggering_user_identity got a 403 from the installation token and fell back to configurable['github_login'], so commits were still authored as the triggering user (commit=user, push+PR=bot — a three-way split that missed the stated goal). Now gate the triggering-user identity resolution on the same default-bot decision as the token: slack/dashboard/schedule default to the app bot identity unless author_prs_as_user is set. * docs(security): record AUTHZ-SLACK-BOT-DEFAULT-001 as an accepted residual (#59) Single-user deployment; bounded by App-on-pilot + ALLOWED_GITHUB_REPOS lock. Revisit (add a per-user gate) before expanding users or the App installation. --- .gitignore | 1 + .security-review/suppressions.json | 10 +++++ agent/dashboard/agent_overrides.py | 17 ++++++++ agent/prompt.py | 2 +- agent/reviewer_reconcile.py | 4 +- agent/server.py | 29 ++++++++++++-- agent/tools/open_pull_request.py | 28 ++++++++----- agent/tools/reply_to_finding_thread.py | 2 +- agent/utils/auth.py | 35 +++++++++------- agent/utils/authorship.py | 17 +++++--- agent/utils/github_org_membership.py | 7 +++- agent/webapp.py | 2 +- tests/test_app_bot_identity.py | 34 ++++++++++++++++ tests/test_auth_sources.py | 48 +++++++++++++++++++--- tests/test_open_pull_request.py | 55 +++++++++++++++++++++++++- 15 files changed, 245 insertions(+), 46 deletions(-) create mode 100644 tests/test_app_bot_identity.py diff --git a/.gitignore b/.gitignore index 5ea41695..6153a317 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ __pycache__/ TODO.md # infra/cdk-outputs.json +.idea/ diff --git a/.security-review/suppressions.json b/.security-review/suppressions.json index 50fed478..9fa487b8 100644 --- a/.security-review/suppressions.json +++ b/.security-review/suppressions.json @@ -1,5 +1,15 @@ { "suppressions": [ + { + "id": "AUTHZ-SLACK-BOT-DEFAULT-001", + "title": "Slack entrypoint lacks a per-user repo-access check; default-bot PR authoring removes the implicit per-user repo boundary", + "file": "agent/webapp.py", + "severity": "medium", + "status": "confirmed", + "suppression_justification": "ACCEPTED (Adam, 2026-06-29) while Open SWE has a SINGLE user. The Slack run entrypoint does not call require_repo_access_for_user (dashboard/schedule do), so with the default App installation token a mapped Slack user could act on any repo in the App's installation regardless of their own access. Bounded by the compensating controls: the seahaven-openswe App is installed on open-swe-pilot ONLY and ALLOWED_GITHUB_REPOS is locked, so the bot token cannot reach repos outside the pilot, and the only triggering user is the owner. Tracked as open issue #59 with three remediation options. REVISIT TRIGGER: before expanding the user base OR broadening the App's installation beyond open-swe-pilot — at that point this becomes HIGH and a gate (option (c): per-user check when a token exists) must be added. Verified medium (not high) by /sh-security-review proof-or-kill verifier.", + "owner": "adam@seahavenind.com", + "added": "2026-06-29" + }, { "id": "OSWE-IAC-SECRETS-LIST-01", "title": "EC2 instance role grants BatchGetSecretValue on \"*\" (operation-level; secret-NAME existence enumeration account-wide)", diff --git a/agent/dashboard/agent_overrides.py b/agent/dashboard/agent_overrides.py index f3a81d04..c518bcb1 100644 --- a/agent/dashboard/agent_overrides.py +++ b/agent/dashboard/agent_overrides.py @@ -92,6 +92,23 @@ def profile_create_prs(profile: dict[str, Any] | None) -> bool: return False +def profile_author_prs_as_user(profile: dict[str, Any] | None) -> bool: + """Return whether Slack/dashboard/schedule PRs should be authored as the + triggering user (their per-user OAuth token) instead of the App bot. + + Defaults to False: by default these PRs (and the run's git/gh operations) are + authored as the GitHub App `seahaven-openswe[bot]`, matching GitHub-issue + runs and making the self-review 422 impossible. Set the profile flag + ``author_prs_as_user: true`` to opt back into per-user attribution. + """ + if not isinstance(profile, dict): + return False + value = profile.get("author_prs_as_user") + if isinstance(value, bool): + return value + return False + + def _normalize_profile_model_pair( profile: dict[str, Any], *, diff --git a/agent/prompt.py b/agent/prompt.py index d918a27f..54ece005 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -391,7 +391,7 @@ When you have completed your implementation, follow these steps in order: 2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications. 3. **Submit**: Commit locally, push with `git push origin `, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting. - - **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). This attributes the PR to the triggering user. Push the branch BEFORE calling it. + - **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). By default the PR is authored by the app (`seahaven-openswe[bot]`), like GitHub-issue-triggered runs (a user can opt back into per-user attribution via the `author_prs_as_user` profile setting). Push the branch BEFORE calling it. - **Update an existing PR** (edit the body, mark ready for review, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted in), do NOT open a duplicate — `open_pull_request` returns the existing PR's URL, so switch to `gh pr edit`. For follow-up changes, add a new commit on top of the existing branch history. **PR Title** (under 70 characters): the title rule is **repo-aware** — first detect whether the target repo enforces a conventional-commit PR title, then pick the matching style. The repo is already cloned, so this check is cheap. diff --git a/agent/reviewer_reconcile.py b/agent/reviewer_reconcile.py index f43e93eb..93d69937 100644 --- a/agent/reviewer_reconcile.py +++ b/agent/reviewer_reconcile.py @@ -16,7 +16,7 @@ ReviewThreadMatch = tuple[ReviewThread, int | None] def _is_open_swe_bot_comment(comment: ReviewThread) -> bool: - return comment.get("author") in {"open-swe", "open-swe[bot]"} + return comment.get("author") in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"} def _int_list(value: Any) -> list[int]: @@ -52,7 +52,7 @@ def _human_replies_after_bot_comment( if not seen_bot_comment: continue author = comment.get("author") - if author in {"open-swe", "open-swe[bot]"}: + if author in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}: continue replies.append(comment) return replies diff --git a/agent/server.py b/agent/server.py index 558825c4..57961ee0 100644 --- a/agent/server.py +++ b/agent/server.py @@ -36,6 +36,7 @@ from .dashboard.agent_overrides import ( load_profile, normalize_profile_overrides, normalize_profile_subagent_overrides, + profile_author_prs_as_user, profile_create_prs, resolve_github_login, ) @@ -681,9 +682,31 @@ async def get_agent(config: RunnableConfig) -> Pregel: profile_login = resolve_github_login(config) configurable = (config or {}).get("configurable") or {} prompt_default_repo = await _resolve_prompt_default_repo(configurable) - triggering_user_identity_task = asyncio.create_task( - asyncio.to_thread(resolve_triggering_user_identity, config, github_token) - ) + + # Commit identity must follow the SAME default-bot decision as the token + # (SH-IDSPLIT-01): by default slack/dashboard/schedule runs author commits as the + # app bot, so resolve the triggering USER's git identity ONLY when authoring as the + # user (the author_prs_as_user opt-in, or a non-default source). Otherwise leave it + # None so construct_system_prompt sets the bot identity (OPEN_SWE_BOT_NAME/EMAIL) and + # commits don't get mis-attributed to a human who didn't write them. + if configurable.get("source") in ("slack", "dashboard", "schedule"): + _author_as_user = bool( + isinstance(profile_login, str) + and profile_login.strip() + and profile_author_prs_as_user(await load_profile(profile_login.strip())) + ) + else: + _author_as_user = True + + async def _no_triggering_identity() -> Any: + return None + + if _author_as_user: + triggering_user_identity_task = asyncio.create_task( + asyncio.to_thread(resolve_triggering_user_identity, config, github_token) + ) + else: + triggering_user_identity_task = asyncio.create_task(_no_triggering_identity()) sandbox_task = asyncio.create_task( ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo) ) diff --git a/agent/tools/open_pull_request.py b/agent/tools/open_pull_request.py index 2a1193e1..a24fb650 100644 --- a/agent/tools/open_pull_request.py +++ b/agent/tools/open_pull_request.py @@ -25,11 +25,12 @@ _REFERENCES_HEADING = "## References" async def _resolve_pr_author_token() -> tuple[str | None, str]: """Return ``(token, kind)`` for opening the PR. - Prefers the triggering user's OAuth token (so the PR is created *as them*) - for Slack/dashboard runs with a mapped GitHub login, resolving it by login - from the dashboard OAuth store. Falls back to the GitHub App installation - token (creator = open-swe[bot]) for GitHub-triggered runs, unmapped users, - or bot-token-only deployments — preserving today's behavior. + 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). 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 @@ -40,12 +41,19 @@ async def _resolve_pr_author_token() -> tuple[str | None, str]: github_login = configurable.get("github_login") if source in _USER_TOKEN_SOURCES and isinstance(github_login, str) and github_login.strip(): - from ..dashboard.profiles import get_valid_access_token + login = github_login.strip() + from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user - user_token = await get_valid_access_token(github_login.strip()) - if user_token: - return user_token, "user" - logger.info("No valid user token for %s; opening PR as open-swe[bot]", github_login.strip()) + if profile_author_prs_as_user(await load_profile(login)): + from ..dashboard.profiles import get_valid_access_token + + user_token = await get_valid_access_token(login) + if user_token: + return user_token, "user" + logger.info( + "author_prs_as_user set but no valid user token for %s; opening PR as the app bot", + login, + ) return await get_github_app_installation_token(), "bot" diff --git a/agent/tools/reply_to_finding_thread.py b/agent/tools/reply_to_finding_thread.py index ebfe0fe7..27b66fff 100644 --- a/agent/tools/reply_to_finding_thread.py +++ b/agent/tools/reply_to_finding_thread.py @@ -92,7 +92,7 @@ async def _reply_to_finding_thread_async( "kind": "bot_reply", "github_comment_id": reply_id if isinstance(reply_id, int) else None, "github_parent_comment_id": comment_id, - "author": "open-swe[bot]", + "author": "seahaven-openswe[bot]", "body": body.strip(), "created_at": "", "needs_reassessment": False, diff --git a/agent/utils/auth.py b/agent/utils/auth.py index 53ea6c1e..a5cefa59 100644 --- a/agent/utils/auth.py +++ b/agent/utils/auth.py @@ -440,26 +440,31 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[ github_login = configurable.get("github_login") - # Per-user OAuth from the dashboard store wins even in bot-token-only mode, - # for sources that carry a mapped GitHub login (Slack, dashboard). This is - # what lets the agent open PRs as the triggering user. + # 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. if ( source in ("slack", "dashboard", "schedule") and isinstance(github_login, str) and github_login.strip() ): - try: - user_token = await _resolve_dashboard_user_token(thread_id, github_login) - except ValueError as exc: - logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc)) - raise RuntimeError(str(exc)) from exc - if user_token is not None: - return user_token - # No valid user token. In bot-token-only mode fall back to the bot so the - # deployment stays functional; otherwise block and require auth. - if is_bot_token_only_mode(): - return await _resolve_bot_installation_token(thread_id) - raise GitHubUserAuthRequired(source, github_login) + from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user + + if profile_author_prs_as_user(await load_profile(github_login.strip())): + try: + user_token = await _resolve_dashboard_user_token(thread_id, github_login) + except ValueError as exc: + logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc)) + raise RuntimeError(str(exc)) from exc + if user_token is not None: + return user_token + # Opt-in set but no valid user token: in bot-token-only mode fall back + # to the bot; otherwise block and require auth. + if not is_bot_token_only_mode(): + raise GitHubUserAuthRequired(source, github_login) + return await _resolve_bot_installation_token(thread_id) if is_bot_token_only_mode(): return await _resolve_bot_installation_token(thread_id) diff --git a/agent/utils/authorship.py b/agent/utils/authorship.py index a632f7b2..8dd5d2f6 100644 --- a/agent/utils/authorship.py +++ b/agent/utils/authorship.py @@ -10,11 +10,18 @@ import httpx logger = logging.getLogger(__name__) -OPEN_SWE_BOT_NAME = "open-swe[bot]" -# Use the open-swe user noreply address: the bot's numeric noreply -# (215916821+open-swe[bot]@...) doesn't resolve to a GitHub account Vercel -# accepts, which broke preview deploys on commits carrying this co-author. -OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com" +# Sea Haven fork identity: commits AND PRs come in as our GitHub App bot +# `seahaven-openswe[bot]` (user id 296972425) so Slack/dashboard/schedule runs are +# attributed to the app, not the triggering user (deterministic; eliminates the +# self-review 422). The numeric noreply is the canonical GitHub form. +# NOTE (Adam, 2026-06-29 — ACCEPTED RISK): the `+@users.noreply` form +# may NOT resolve to a GitHub account Vercel preview deploys accept (the upstream +# `open-swe[bot]` hit exactly this and worked around it with a non-numeric +# address). We deliberately accept that risk here in exchange for a consistent +# bot identity across commits + PRs. If Vercel preview deploys on a target repo +# start rejecting our commits, this is the cause — revert to a resolvable address. +OPEN_SWE_BOT_NAME = "seahaven-openswe[bot]" +OPEN_SWE_BOT_EMAIL = "296972425+seahaven-openswe[bot]@users.noreply.github.com" @dataclass(frozen=True) diff --git a/agent/utils/github_org_membership.py b/agent/utils/github_org_membership.py index 5c972264..95248c31 100644 --- a/agent/utils/github_org_membership.py +++ b/agent/utils/github_org_membership.py @@ -10,7 +10,12 @@ from .github_app import get_github_app_installation_token logger = logging.getLogger(__name__) -INTERNAL_BOT_LOGINS: frozenset[str] = frozenset({"open-swe[bot]", "openswe-dev[bot]"}) +# Bot logins whose webhook events are our OWN actions — never re-process them +# (self-trigger guard). Sea Haven's App is `seahaven-openswe[bot]`; the upstream +# `open-swe[bot]` / `openswe-dev[bot]` are kept for historical/test events. +INTERNAL_BOT_LOGINS: frozenset[str] = frozenset( + {"seahaven-openswe[bot]", "open-swe[bot]", "openswe-dev[bot]"} +) async def is_user_active_org_member(username: str, org: str) -> bool: diff --git a/agent/webapp.py b/agent/webapp.py index f686ba16..2577b804 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -3179,7 +3179,7 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None: sender = payload.get("sender", {}) sender_login = sender.get("login") if isinstance(sender, dict) else None - if sender_login == "open-swe[bot]": + if sender_login in INTERNAL_BOT_LOGINS: return repo = payload.get("repository", {}) diff --git a/tests/test_app_bot_identity.py b/tests/test_app_bot_identity.py new file mode 100644 index 00000000..caafda45 --- /dev/null +++ b/tests/test_app_bot_identity.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from agent.dashboard.agent_overrides import profile_author_prs_as_user +from agent.utils.authorship import OPEN_SWE_BOT_EMAIL, OPEN_SWE_BOT_NAME +from agent.utils.github_org_membership import INTERNAL_BOT_LOGINS + + +def test_author_prs_as_user_defaults_off() -> None: + # Default is the app bot; the per-user opt-in must be explicit. + assert profile_author_prs_as_user(None) is False + assert profile_author_prs_as_user({}) is False + assert profile_author_prs_as_user({"author_prs_as_user": "true"}) is False + assert profile_author_prs_as_user({"author_prs_as_user": True}) is True + + +def test_commit_identity_is_the_app_bot() -> None: + assert OPEN_SWE_BOT_NAME == "seahaven-openswe[bot]" + assert OPEN_SWE_BOT_EMAIL == "296972425+seahaven-openswe[bot]@users.noreply.github.com" + + +def test_self_trigger_guard_recognizes_our_app_bot() -> None: + # Bot-authored PRs/actions must be recognized as our own to avoid self-trigger loops. + assert "seahaven-openswe[bot]" in INTERNAL_BOT_LOGINS + + +def test_default_commit_identity_in_prompt_is_the_app_bot() -> None: + # SH-IDSPLIT-01: with no triggering-user identity (the default-bot path), the + # constructed prompt must set the git commit identity to the app bot — so + # commits, push, and PR all come in as the app (not the triggering user). + from agent.prompt import construct_system_prompt + + prompt = construct_system_prompt(working_dir="/workspace", triggering_user_identity=None) + assert OPEN_SWE_BOT_NAME in prompt + assert OPEN_SWE_BOT_EMAIL in prompt diff --git a/tests/test_auth_sources.py b/tests/test_auth_sources.py index 9ae658aa..c19f5250 100644 --- a/tests/test_auth_sources.py +++ b/tests/test_auth_sources.py @@ -78,10 +78,42 @@ def _stub_dashboard_store( monkeypatch.setattr(profiles, "_get_value", fake_get_value) -def test_resolve_github_token_slack_uses_dashboard_store( +def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None: + """Mock the per-user profile lookup that gates per-user attribution. + + ``author_prs_as_user=False`` → no opt-in (default: author as the app bot). + """ + from agent.dashboard import agent_overrides + + profile = {"author_prs_as_user": True} if author_prs_as_user else None + + async def fake_load_profile(login: str): + return profile + + monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile) + + +def test_resolve_github_token_slack_defaults_to_bot(monkeypatch: pytest.MonkeyPatch) -> None: + # DEFAULT (no author_prs_as_user opt-in): slack runs author as the app bot, + # even when a valid per-user token exists — so PRs come in as the app. + _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(_slack_config(), "t1")) + assert token == "bot-tok" + + +def test_resolve_github_token_slack_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(_slack_config(), "t1")) @@ -90,7 +122,7 @@ def test_resolve_github_token_slack_uses_dashboard_store( assert expires_at == "2099-01-01T00:00:00Z" -def test_resolve_github_token_slack_ignores_stale_thread_cache( +def test_resolve_github_token_slack_optin_ignores_stale_thread_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: # Slack thread ids are shared, so a prior user's cached token must NOT be @@ -100,6 +132,7 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache( token="bob-token", cached=("alice-token", "2099-01-01T00:00:00Z"), ) + _set_profile(monkeypatch, author_prs_as_user=True) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False) token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1")) @@ -107,24 +140,26 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache( assert token == "bob-token" -def test_resolve_github_token_slack_no_token_raises( +def test_resolve_github_token_slack_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(_slack_config(), "t1")) -def test_resolve_github_token_per_user_wins_over_bot_only_mode( +def test_resolve_github_token_optin_per_user_wins_over_bot_only_mode( 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: True) async def fail_bot(thread_id: str): - raise AssertionError("bot token must not be used when a user token exists") + raise AssertionError("bot token must not be used when the opt-in user token exists") monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot) @@ -132,10 +167,11 @@ def test_resolve_github_token_per_user_wins_over_bot_only_mode( assert token == "user-tok" -def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode( +def test_resolve_github_token_slack_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): diff --git a/tests/test_open_pull_request.py b/tests/test_open_pull_request.py index 52dbfa09..5214029b 100644 --- a/tests/test_open_pull_request.py +++ b/tests/test_open_pull_request.py @@ -11,6 +11,24 @@ import agent.tools.open_pull_request # noqa: F401 opr = sys.modules["agent.tools.open_pull_request"] +def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None: + """Mock the per-user profile lookup that gates per-user PR attribution.""" + from agent.dashboard import agent_overrides + + profile = {"author_prs_as_user": True} if author_prs_as_user else None + + async def fake_load_profile(login: str): + return profile + + monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile) + + +@pytest.fixture(autouse=True) +def _default_no_optin_profile(monkeypatch: pytest.MonkeyPatch) -> None: + # Default: the author_prs_as_user opt-in is OFF, so PRs author as the app bot. + _set_profile(monkeypatch, author_prs_as_user=False) + + class _FakeResponse: def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None: self.status_code = status_code @@ -101,11 +119,46 @@ def _open() -> dict[str, Any]: ) -def test_uses_user_token_for_slack_with_login(monkeypatch: pytest.MonkeyPatch) -> None: +def test_defaults_to_bot_for_slack_without_optin(monkeypatch: pytest.MonkeyPatch) -> None: + # DEFAULT (no author_prs_as_user opt-in): slack PRs are opened as the app bot, + # even when a valid per-user token exists — so the PR is attributed to the app. _set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"}) from agent.dashboard import profiles + async def fail_user_token(login: str, **_kw: Any) -> str | None: + raise AssertionError("user token must not be used without the author_prs_as_user opt-in") + + monkeypatch.setattr(profiles, "get_valid_access_token", fail_user_token) + + async def fake_bot() -> str | None: + return "bot-tok" + + monkeypatch.setattr(opr, "get_github_app_installation_token", fake_bot) + + client = _FakeClient( + post=_FakeResponse( + 201, + { + "html_url": "https://x/pull/1", + "number": 1, + "user": {"login": "seahaven-openswe[bot]"}, + }, + ) + ) + _install_client(monkeypatch, client) + + result = _open() + assert result["token_kind"] == "bot" + assert client.post_calls[0]["headers"]["Authorization"] == "Bearer bot-tok" + + +def test_uses_user_token_for_slack_with_optin(monkeypatch: pytest.MonkeyPatch) -> None: + _set_config(monkeypatch, {"source": "slack", "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"