mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
* feat: open Slack-triggered PRs as the triggering user Route the Slack per-user GitHub token through the dashboard OAuth store (the backend the self-service link prompt populates) and block runs that lack a valid user token, prompting the user to (re-)link. Per-user OAuth now wins over bot-token-only mode for mapped Slack/dashboard users. Flip commit/PR authorship across all sources: the triggering user is the commit author (via repo-local git identity using their resolvable GitHub noreply email) and open-swe[bot] is the Co-authored-by collaborator. * fix: address PR review — shell-escape commit identity, fix token cache impersonation - Shell-escape the triggering user's name/email with shlex.quote before embedding them in the repo-setup `git config` command, so a name like O'Connor (or a crafted one) can't break or inject into the command. - Stop consulting the shared thread-metadata token cache in _resolve_dashboard_user_token. Slack thread ids are shared across the conversation, so a cached token from a prior triggering user could be returned for the current github_login. Always resolve by login from the dashboard OAuth store instead. * feat: dashboard self-service user mapping + UI cleanup - Add session-scoped GET/PUT /dashboard/api/my-mapping so users can set their own work email / Slack member ID (keyed by their GitHub login, source=self). - Slack account-link prompt now redirects to Profile Settings after auth. - Rename "My Settings" -> "Profile Settings" and "Cloud Agents" -> "Open SWE Agent"; remove the Integrations tab/section (folded out, low value for now) and redirect /integrations to Profile Settings. - Add a "User mapping" section to Profile Settings (work email used by Slack and Linear, optional Slack member ID). - Make dashboard auth cookies scheme-aware: Secure;SameSite=None over HTTPS, non-Secure;SameSite=Lax over http://localhost so local login works. * feat: self-service Slack account linking via Sign in with Slack (OIDC) Replace the spoofable manual work-email/Slack-ID form with a verified "Sign in with Slack" flow so a logged-in GitHub user can only ever link their own Slack identity. - New agent/dashboard/slack_oauth.py: OIDC authorize URL, code exchange, userInfo identity parse, optional workspace gate, configured check. - routes.py: session-gated GET /slack/login and /slack/callback that upsert the mapping from Slack-verified user_id + email (source=slack_oauth). Remove the spoofable PUT /my-mapping; expose slack_oauth_enabled on /me. - UI: drop the editable inputs; add a Connect Slack button + status to the User mapping section. Admin-managed mappings are unaffected and still resolve at trigger time.
211 lines
7 KiB
Python
211 lines
7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from agent.utils import auth
|
|
|
|
|
|
def test_leave_failure_comment_posts_to_slack_thread(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
called: dict[str, str] = {}
|
|
|
|
async def fake_post_slack_ephemeral_message(
|
|
channel_id: str, user_id: str, text: str, thread_ts: str | None = None
|
|
) -> bool:
|
|
called["channel_id"] = channel_id
|
|
called["user_id"] = user_id
|
|
called["thread_ts"] = thread_ts
|
|
called["message"] = text
|
|
return True
|
|
|
|
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
|
|
raise AssertionError("post_slack_thread_reply should not be called when ephemeral succeeds")
|
|
|
|
monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message)
|
|
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
|
monkeypatch.setattr(
|
|
auth,
|
|
"get_config",
|
|
lambda: {
|
|
"configurable": {
|
|
"slack_thread": {
|
|
"channel_id": "C123",
|
|
"thread_ts": "1.2",
|
|
"triggering_user_id": "U123",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
|
|
|
|
assert called == {
|
|
"channel_id": "C123",
|
|
"user_id": "U123",
|
|
"thread_ts": "1.2",
|
|
"message": "auth failed",
|
|
}
|
|
|
|
|
|
def test_leave_failure_comment_falls_back_to_slack_thread_when_ephemeral_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
thread_called: dict[str, str] = {}
|
|
|
|
async def fake_post_slack_ephemeral_message(
|
|
channel_id: str, user_id: str, text: str, thread_ts: str | None = None
|
|
) -> bool:
|
|
return False
|
|
|
|
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, message: str) -> bool:
|
|
thread_called["channel_id"] = channel_id
|
|
thread_called["thread_ts"] = thread_ts
|
|
thread_called["message"] = message
|
|
return True
|
|
|
|
monkeypatch.setattr(auth, "post_slack_ephemeral_message", fake_post_slack_ephemeral_message)
|
|
monkeypatch.setattr(auth, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
|
monkeypatch.setattr(
|
|
auth,
|
|
"get_config",
|
|
lambda: {
|
|
"configurable": {
|
|
"slack_thread": {
|
|
"channel_id": "C123",
|
|
"thread_ts": "1.2",
|
|
"triggering_user_id": "U123",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
|
|
|
|
assert thread_called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"}
|
|
|
|
|
|
def _slack_config(github_login: str | None = "mason-gh") -> dict:
|
|
configurable: dict = {
|
|
"source": "slack",
|
|
"user_email": "mason@example.com",
|
|
"thread_id": "t1",
|
|
}
|
|
if github_login is not None:
|
|
configurable["github_login"] = github_login
|
|
return {"configurable": configurable}
|
|
|
|
|
|
def _stub_dashboard_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
token: str | None,
|
|
expires_at: str | None = "2099-01-01T00:00:00Z",
|
|
cached: tuple[str | None, str | None, str | None] = (None, None, None),
|
|
) -> None:
|
|
from agent.dashboard import profiles
|
|
|
|
async def fake_get_from_thread(thread_id: str):
|
|
return cached
|
|
|
|
async def fake_get_valid(login: str):
|
|
return token
|
|
|
|
async def fake_get_value(namespace, key):
|
|
return {"token_expires_at": expires_at}
|
|
|
|
async def fake_persist(thread_id: str, tok: str, expires_at: str | None = None):
|
|
return "enc"
|
|
|
|
monkeypatch.setattr(auth, "get_github_token_from_thread", fake_get_from_thread)
|
|
monkeypatch.setattr(auth, "persist_encrypted_github_token", fake_persist)
|
|
monkeypatch.setattr(profiles, "get_valid_access_token", fake_get_valid)
|
|
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
|
|
|
|
|
|
def test_resolve_github_token_slack_uses_dashboard_store(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token="user-tok")
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
|
|
|
|
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
|
|
assert token == "user-tok"
|
|
assert encrypted == "enc"
|
|
assert expires_at == "2099-01-01T00:00:00Z"
|
|
|
|
|
|
def test_resolve_github_token_slack_ignores_stale_thread_cache(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# Slack thread ids are shared, so a prior user's cached token must NOT be
|
|
# returned. Resolution always goes by github_login via the dashboard store.
|
|
_stub_dashboard_store(
|
|
monkeypatch,
|
|
token="bob-token",
|
|
cached=("alice-token", "alice-enc", "2099-01-01T00:00:00Z"),
|
|
)
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
|
|
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
|
|
assert token == "bob-token"
|
|
|
|
|
|
def test_resolve_github_token_slack_no_token_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token=None)
|
|
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(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token="user-tok")
|
|
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")
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
|
|
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
assert token == "user-tok"
|
|
|
|
|
|
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_stub_dashboard_store(monkeypatch, token=None)
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
|
|
|
|
async def fake_bot(thread_id: str):
|
|
return ("bot-tok", "bot-enc", None)
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
|
|
|
|
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
|
|
assert (token, encrypted, expires_at) == ("bot-tok", "bot-enc", None)
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["github", "linear"])
|
|
def test_resolve_github_token_bot_only_mode_non_slack_uses_bot(
|
|
monkeypatch: pytest.MonkeyPatch, source: str
|
|
) -> None:
|
|
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
|
|
|
|
async def fake_bot(thread_id: str):
|
|
return ("bot-tok", "bot-enc", None)
|
|
|
|
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
|
|
|
|
config = {"configurable": {"source": source, "github_login": "octo", "thread_id": "t1"}}
|
|
token, _, _ = asyncio.run(auth.resolve_github_token(config, "t1"))
|
|
assert token == "bot-tok"
|