feat: author Slack/dashboard/schedule commits + PRs as the app by default (#57) (#60)
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled

* 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.
This commit is contained in:
Adam Moussa 2026-06-29 14:22:33 -04:00 • committed by GitHub
parent 134963647b
commit 8a9974c3c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 245 additions and 46 deletions

1
.gitignore vendored
View file

@ -72,3 +72,4 @@ __pycache__/
TODO.md TODO.md
# infra/cdk-outputs.json # infra/cdk-outputs.json
.idea/

View file

@ -1,5 +1,15 @@
{ {
"suppressions": [ "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", "id": "OSWE-IAC-SECRETS-LIST-01",
"title": "EC2 instance role grants BatchGetSecretValue on \"*\" (operation-level; secret-NAME existence enumeration account-wide)", "title": "EC2 instance role grants BatchGetSecretValue on \"*\" (operation-level; secret-NAME existence enumeration account-wide)",

View file

@ -92,6 +92,23 @@ def profile_create_prs(profile: dict[str, Any] | None) -> bool:
return False 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( def _normalize_profile_model_pair(
profile: dict[str, Any], profile: dict[str, Any],
*, *,

View file

@ -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. 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 <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting. 3. **Submit**: Commit locally, push with `git push origin <branch>`, 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. - **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. **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.

View file

@ -16,7 +16,7 @@ ReviewThreadMatch = tuple[ReviewThread, int | None]
def _is_open_swe_bot_comment(comment: ReviewThread) -> bool: 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]: def _int_list(value: Any) -> list[int]:
@ -52,7 +52,7 @@ def _human_replies_after_bot_comment(
if not seen_bot_comment: if not seen_bot_comment:
continue continue
author = comment.get("author") author = comment.get("author")
if author in {"open-swe", "open-swe[bot]"}: if author in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}:
continue continue
replies.append(comment) replies.append(comment)
return replies return replies

View file

@ -36,6 +36,7 @@ from .dashboard.agent_overrides import (
load_profile, load_profile,
normalize_profile_overrides, normalize_profile_overrides,
normalize_profile_subagent_overrides, normalize_profile_subagent_overrides,
profile_author_prs_as_user,
profile_create_prs, profile_create_prs,
resolve_github_login, resolve_github_login,
) )
@ -681,9 +682,31 @@ async def get_agent(config: RunnableConfig) -> Pregel:
profile_login = resolve_github_login(config) profile_login = resolve_github_login(config)
configurable = (config or {}).get("configurable") or {} configurable = (config or {}).get("configurable") or {}
prompt_default_repo = await _resolve_prompt_default_repo(configurable) 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( sandbox_task = asyncio.create_task(
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo) ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
) )

View file

@ -25,11 +25,12 @@ _REFERENCES_HEADING = "## References"
async def _resolve_pr_author_token() -> tuple[str | None, str]: async def _resolve_pr_author_token() -> tuple[str | None, str]:
"""Return ``(token, kind)`` for opening the PR. """Return ``(token, kind)`` for opening the PR.
Prefers the triggering user's OAuth token (so the PR is created *as them*) DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
for Slack/dashboard runs with a mapped GitHub login, resolving it by login installation token) for every source, so Slack/dashboard PRs are attributed
from the dashboard OAuth store. Falls back to the GitHub App installation to the app — matching GitHub-issue runs and making the self-review 422
token (creator = open-swe[bot]) for GitHub-triggered runs, unmapped users, impossible by construction. OPT-IN: when the triggering user's profile has
or bot-token-only deployments — preserving today's behavior. ``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 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 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") github_login = configurable.get("github_login")
if source in _USER_TOKEN_SOURCES and isinstance(github_login, str) and github_login.strip(): 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 profile_author_prs_as_user(await load_profile(login)):
if user_token: from ..dashboard.profiles import get_valid_access_token
return user_token, "user"
logger.info("No valid user token for %s; opening PR as open-swe[bot]", github_login.strip()) 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" return await get_github_app_installation_token(), "bot"

View file

@ -92,7 +92,7 @@ async def _reply_to_finding_thread_async(
"kind": "bot_reply", "kind": "bot_reply",
"github_comment_id": reply_id if isinstance(reply_id, int) else None, "github_comment_id": reply_id if isinstance(reply_id, int) else None,
"github_parent_comment_id": comment_id, "github_parent_comment_id": comment_id,
"author": "open-swe[bot]", "author": "seahaven-openswe[bot]",
"body": body.strip(), "body": body.strip(),
"created_at": "", "created_at": "",
"needs_reassessment": False, "needs_reassessment": False,

View file

@ -440,26 +440,31 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
github_login = configurable.get("github_login") github_login = configurable.get("github_login")
# Per-user OAuth from the dashboard store wins even in bot-token-only mode, # DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token,
# for sources that carry a mapped GitHub login (Slack, dashboard). This is # so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]`
# what lets the agent open PRs as the triggering user. # (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 ( if (
source in ("slack", "dashboard", "schedule") source in ("slack", "dashboard", "schedule")
and isinstance(github_login, str) and isinstance(github_login, str)
and github_login.strip() and github_login.strip()
): ):
try: from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc: if profile_author_prs_as_user(await load_profile(github_login.strip())):
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc)) try:
raise RuntimeError(str(exc)) from exc user_token = await _resolve_dashboard_user_token(thread_id, github_login)
if user_token is not None: except ValueError as exc:
return user_token logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
# No valid user token. In bot-token-only mode fall back to the bot so the raise RuntimeError(str(exc)) from exc
# deployment stays functional; otherwise block and require auth. if user_token is not None:
if is_bot_token_only_mode(): return user_token
return await _resolve_bot_installation_token(thread_id) # Opt-in set but no valid user token: in bot-token-only mode fall back
raise GitHubUserAuthRequired(source, github_login) # 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(): if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id) return await _resolve_bot_installation_token(thread_id)

View file

@ -10,11 +10,18 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPEN_SWE_BOT_NAME = "open-swe[bot]" # Sea Haven fork identity: commits AND PRs come in as our GitHub App bot
# Use the open-swe user noreply address: the bot's numeric noreply # `seahaven-openswe[bot]` (user id 296972425) so Slack/dashboard/schedule runs are
# (215916821+open-swe[bot]@...) doesn't resolve to a GitHub account Vercel # attributed to the app, not the triggering user (deterministic; eliminates the
# accepts, which broke preview deploys on commits carrying this co-author. # self-review 422). The numeric noreply is the canonical GitHub form.
OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com" # NOTE (Adam, 2026-06-29 — ACCEPTED RISK): the `<id>+<login>@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) @dataclass(frozen=True)

View file

@ -10,7 +10,12 @@ from .github_app import get_github_app_installation_token
logger = logging.getLogger(__name__) 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: async def is_user_active_org_member(username: str, org: str) -> bool:

View file

@ -3179,7 +3179,7 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
sender = payload.get("sender", {}) sender = payload.get("sender", {})
sender_login = sender.get("login") if isinstance(sender, dict) else None 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 return
repo = payload.get("repository", {}) repo = payload.get("repository", {})

View file

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

View file

@ -78,10 +78,42 @@ def _stub_dashboard_store(
monkeypatch.setattr(profiles, "_get_value", fake_get_value) 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok") _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) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1")) 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" 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
# Slack thread ids are shared, so a prior user's cached token must NOT be # 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", token="bob-token",
cached=("alice-token", "2099-01-01T00:00:00Z"), 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) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1")) 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" 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_dashboard_store(monkeypatch, token=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) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired): with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_slack_config(), "t1")) 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok") _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) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fail_bot(thread_id: str): 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) 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" 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_stub_dashboard_store(monkeypatch, token=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) monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str): async def fake_bot(thread_id: str):

View file

@ -11,6 +11,24 @@ import agent.tools.open_pull_request # noqa: F401
opr = sys.modules["agent.tools.open_pull_request"] 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: class _FakeResponse:
def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None: def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None:
self.status_code = status_code 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"}) _set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
from agent.dashboard import profiles 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: async def fake_user_token(login: str, **_kw: Any) -> str | None:
assert login == "johannes117" assert login == "johannes117"
return "user-tok" return "user-tok"