mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
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
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:
parent
134963647b
commit
8a9974c3c4
15 changed files with 245 additions and 46 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -72,3 +72,4 @@ __pycache__/
|
|||
TODO.md
|
||||
|
||||
# infra/cdk-outputs.json
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 `<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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
34
tests/test_app_bot_identity.py
Normal file
34
tests/test_app_bot_identity.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue